@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
@@ -9,11 +9,14 @@ export class MacParakeetUnavailableError extends Error {
9
9
  readonly status = 503
10
10
  readonly retryable = true
11
11
  readonly model = 'parakeet-ultra'
12
- readonly reason: `mac_parakeet_${ParakeetFailure}` | `phone_parakeet_${ParakeetFailure}`
12
+ readonly reason: `mac_parakeet_${ParakeetFailure | 'internal'}` | `phone_parakeet_${ParakeetFailure | 'internal'}`
13
13
 
14
14
  constructor(cause: unknown, source: 'Mac' | 'iPhone' = 'Mac') {
15
- const reason = cause instanceof ParakeetError ? cause.reason : 'cli_failed'
16
- super(`Selected Parakeet Ultra could not transcribe this ${source} chunk (${reason}); audio is preserved for retry`)
15
+ // Anything but a ParakeetError never reached the CLI. Calling it cli_failed sent a stuck
16
+ // meeting's diagnosis to a tool that had exited 0 on every window (2026-10-31).
17
+ const reason = cause instanceof ParakeetError ? cause.reason : 'internal'
18
+ const diagnostic = cause instanceof ParakeetError && cause.reason === 'cli_failed' ? `; ${cause.message}` : ''
19
+ super(`Selected Parakeet Ultra could not transcribe this ${source} chunk (${reason})${diagnostic}; audio is preserved for retry`)
17
20
  this.name = 'MacParakeetUnavailableError'
18
21
  this.reason = `${source === 'Mac' ? 'mac' : 'phone'}_parakeet_${reason}`
19
22
  }
@@ -0,0 +1,371 @@
1
+ /** Explicit human annotations over an immutable automatic BASE. These are not
2
+ * acoustic assertions or enrollment. The authenticated route owns authorization;
3
+ * hashes bind the reviewed source and ledger, not the identity of a human. */
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync, type Stats } from 'node:fs'
6
+ import { dirname } from 'node:path'
7
+ import { dataPath } from './data-dir.js'
8
+ import { checkSpeakerName } from './speaker-name.js'
9
+ import { hasMacSpeakerProtectedRows, isMacSpeakerHumanProtected } from './mac-speaker-protection.js'
10
+ import { boundWords, renderMarkdown } from './mac-speaker-repair-render.js'
11
+ import type { MacSpeakerCallPacket, MacSpeakerCallView } from './mac-speaker-call-view.js'
12
+
13
+ type Doc = Record<string, any>
14
+ type Path = Array<string | number>
15
+ const VOICE = /^Voice (?:[1-9]|[1-9]\d)$/
16
+ const SHA = /^[a-f0-9]{64}$/
17
+ const PREFIX = 'mac-speaker-call-human-v1'
18
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
19
+ const digest = (value: unknown) => hash(JSON.stringify(value))
20
+ const fail = (reason: string): never => { throw new Error(`Mac call human: ${reason}`) }
21
+ const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
22
+ /** Human annotations require a person's name, never a machine placeholder.
23
+ * Keep this local: the shared profile-name checker has other consumers. */
24
+ export function isMacCallHumanName(value: unknown): value is string {
25
+ return typeof value === 'string' && value === value.trim() && checkSpeakerName(value).ok
26
+ && !/^(?:Ext|Unknown|Unidentified(?:\s+\d+)?|Speaker(?:\s*\d+)?|MU)$/i.test(value)
27
+ }
28
+ interface Member { rawChunkIndex: number; position: number; before: string; text: string; elapsedMs: number; baseBefore?: string }
29
+ interface Field { path: Path; before: string; rawChunkIndices: number[] }
30
+ export interface MacCallHumanScope {
31
+ kind: 'automatic' | 'human'
32
+ reviewBasis: 'accepted-acoustic-group' | 'automatic-label-only' | 'human-authored'
33
+ identityId: string; displayLabel: string; scopeSHA256: string
34
+ members: Member[]; fields: Field[]
35
+ baseSHA256: string; markdownSHA256: string; ledgerSHA256: string; annotationSHA256: string | null
36
+ packetSHA256: string | null; generation: string; packetRevision: number
37
+ }
38
+ // One exact-input entry, capped independently of the per-scope field bound.
39
+ // Frozen outputs cannot be poisoned by a caller retaining a returned reference.
40
+ let scopeCache: { key: string; scopes: MacCallHumanScope[] } | null = null
41
+ function cacheScopes(key: string, scopes: MacCallHumanScope[]): MacCallHumanScope[] {
42
+ let fields = 0
43
+ for (const scope of scopes) {
44
+ fields += scope.fields.length
45
+ for (const member of scope.members) Object.freeze(member)
46
+ for (const field of scope.fields) { Object.freeze(field.path); Object.freeze(field.rawChunkIndices); Object.freeze(field) }
47
+ Object.freeze(scope.members); Object.freeze(scope.fields); Object.freeze(scope)
48
+ }
49
+ Object.freeze(scopes)
50
+ scopeCache = fields <= 250000 ? { key, scopes } : null
51
+ return scopes
52
+ }
53
+ interface Action {
54
+ id: string; at: string; identityId: string; to: string; scopeSHA256: string
55
+ members: Member[]; fields: Field[]; fieldScopeSHA256: string; ledgerStart: number; ledgerEnd: number; ledgerBeforeSHA256: string; ledgerAfterSHA256: string
56
+ }
57
+ interface Annotation {
58
+ version: 1; policy: typeof PREFIX; captureId: string; revision: number
59
+ baseSHA256: string; ledgerSHA256: string; actions: Action[]; annotationSHA256: string
60
+ }
61
+ export interface MacCallHumanFiles { sidecarPath: string; meetingPath: string }
62
+ export function macCallHumanPaths(files: MacCallHumanFiles, captureId: string) {
63
+ if (!files.sidecarPath.endsWith('.g2-chunks.json') || !/^mac_[A-Za-z0-9_-]{1,100}$/.test(captureId)) fail('capture paths')
64
+ const stem = files.sidecarPath.replace(/\.g2-chunks\.json$/, '')
65
+ return { annotation: `${stem}.speaker-call-human.json`, packet: `${stem}.speaker-call.json`, journal: `${stem}.speaker-call-human-transaction.json`,
66
+ ledger: dataPath('meeting-corrections', `${captureId}.jsonl`), lock: `${dirname(files.sidecarPath)}/.${captureId}.speaker-call.lock` }
67
+ }
68
+ function at(doc: Doc, path: Path): any {
69
+ if (!Array.isArray(path) || !path.length || path.length > 8 || path.some(p => typeof p !== 'number' && typeof p !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(String(p)))) fail('field path')
70
+ let row = doc
71
+ for (const part of path.slice(0, -1)) { if (!row || typeof row !== 'object' || !Object.hasOwn(row, part)) fail('field missing'); row = row[part] }
72
+ if (!row || typeof row !== 'object' || !Object.hasOwn(row, path.at(-1)!)) fail('field missing')
73
+ return row
74
+ }
75
+ function mapped(doc: Doc): Member[] {
76
+ if (!Array.isArray(doc.chunks) || !Array.isArray(doc.chunkEntries) || doc.chunkEntries.length > 6000) fail('source bounds')
77
+ const result: Member[] = []
78
+ for (const [raw, e] of doc.chunkEntries.entries()) {
79
+ if (e.chunkIndex !== raw || !e.chunk || typeof e.chunk.text !== 'string') fail('raw source')
80
+ if (!e.chunk.text.trim()) continue
81
+ const position = result.length, root = doc.chunks[position]
82
+ if (!root || ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(root[k], e.chunk[k]))) fail('source join')
83
+ result.push({ rawChunkIndex: raw, position, before: root.speaker, text: root.text.slice(0, 600), elapsedMs: root.elapsed })
84
+ }
85
+ if (result.length !== doc.chunks.length) fail('source join')
86
+ return result
87
+ }
88
+ function seal(body: Omit<Annotation, 'annotationSHA256'>): Annotation { return { ...body, annotationSHA256: digest(body) } }
89
+ function selectedFields(scope: MacCallHumanScope, selected: Set<number>): Field[] {
90
+ if (scope.kind === 'human' && scope.fields.some(f => f.rawChunkIndices.some(raw => selected.has(raw)) && !f.rawChunkIndices.every(raw => selected.has(raw)))) fail('selection cuts a previously human-confirmed word')
91
+ return scope.fields.filter(f => f.rawChunkIndices.every(raw => selected.has(raw)))
92
+ }
93
+ /** Preview the same exact label renderer used after publication. A transcript
94
+ * mismatch refuses the action before any human intent or annotation is written. */
95
+ export function previewMacCallHumanAnnotation(baseJSON: string, markdown: string, view: MacSpeakerCallView, scope: MacCallHumanScope, selectedRawChunkIndices: number[], to: string): { members: Member[]; fields: number } {
96
+ if (!isMacCallHumanName(to) || !selectedRawChunkIndices.length || new Set(selectedRawChunkIndices).size !== selectedRawChunkIndices.length) fail('explicit name and source selection required')
97
+ const selected = new Set(selectedRawChunkIndices), members = scope.members.filter(m => selected.has(m.rawChunkIndex)), doc = structuredClone(view.doc)
98
+ if (members.length !== selected.size) fail('selection outside reviewed group')
99
+ const fields = selectedFields(scope, selected)
100
+ for (const field of fields) at(doc, field.path)[field.path.at(-1)!] = to
101
+ renderMarkdown(markdown, boundWords(JSON.parse(baseJSON), doc))
102
+ return { members, fields: fields.length }
103
+ }
104
+ function validateAnnotation(value: unknown, baseJSON: string, ledger: string): Annotation {
105
+ const a = value as Annotation, base = JSON.parse(baseJSON), map = mapped(base), byRaw = new Map(map.map(m => [m.rawChunkIndex, m]))
106
+ if (!a || a.version !== 1 || a.policy !== PREFIX || a.captureId !== base.sessionId || a.baseSHA256 !== hash(baseJSON) || a.ledgerSHA256 !== hash(ledger) || !Number.isSafeInteger(a.revision) || a.revision < 1 || !Array.isArray(a.actions) || a.actions.length !== a.revision || a.actions.length > 6000) fail('annotation source binding')
107
+ const { annotationSHA256, ...body } = a
108
+ if (!SHA.test(annotationSHA256) || annotationSHA256 !== digest(body)) fail('annotation integrity')
109
+ let previousEnd = 0, fieldCount = 0
110
+ const used = new Map<string, string>(), selected = new Map<number, string>()
111
+ for (const action of a.actions) {
112
+ if (!action || typeof action.id !== 'string' || !action.id || !isMacCallHumanName(action.to) || !SHA.test(action.scopeSHA256) || !Array.isArray(action.members) || !action.members.length || action.members.length > 6000 || new Set(action.members.map(m => m.rawChunkIndex)).size !== action.members.length || !Array.isArray(action.fields) || !action.fields.length || (fieldCount += action.fields.length) > 40000 || !Number.isSafeInteger(action.ledgerStart) || !Number.isSafeInteger(action.ledgerEnd) || action.ledgerStart < previousEnd || action.ledgerEnd <= action.ledgerStart || action.ledgerEnd > ledger.length || action.ledgerBeforeSHA256 !== hash(ledger.slice(0, action.ledgerStart)) || action.ledgerAfterSHA256 !== hash(ledger.slice(0, action.ledgerEnd))) fail('annotation action')
113
+ previousEnd = action.ledgerEnd
114
+ const rows = ledger.slice(action.ledgerStart, action.ledgerEnd).trim().split('\n').map(line => JSON.parse(line))
115
+ const groups = new Map<string, number[]>()
116
+ for (const member of action.members) {
117
+ const actual = byRaw.get(member.rawChunkIndex), c = base.chunkEntries[member.rawChunkIndex]?.chunk, root = base.chunks[member.position]
118
+ const baseBefore = member.baseBefore ?? member.before
119
+ const expected = { ...member }; delete expected.baseBefore; expected.before = baseBefore
120
+ if (!actual || !equal(actual, expected) || !VOICE.test(baseBefore) || c.speaker !== baseBefore || hasMacSpeakerProtectedRows(c) || hasMacSpeakerProtectedRows(root)
121
+ || (member.baseBefore === undefined ? selected.has(member.rawChunkIndex) : selected.get(member.rawChunkIndex) !== member.before)) fail('human member source')
122
+ const positions = groups.get(member.before) ?? []; positions.push(member.position); groups.set(member.before, positions)
123
+ }
124
+ if (action.fieldScopeSHA256 !== digest({ identityId: action.identityId, members: action.members, fields: action.fields })) fail('human reviewed field scope')
125
+ const expectedRows = [...groups].flatMap(([from, chunks], index) => ['intent', 'applied', 'confirmed-chunks'].map(phase => ({ id: `${action.id}:${index}`, batchId: action.id, phase, at: action.at, from, to: action.to, chunks, scope: 'meeting', source: PREFIX, fieldScopeSHA256: action.fieldScopeSHA256 })))
126
+ if (!equal(rows, expectedRows)) fail('human ledger evidence')
127
+ const actionRaw = new Set(action.members.map(m => m.rawChunkIndex))
128
+ for (const field of action.fields) {
129
+ const key = JSON.stringify(field.path), row = at(base, field.path), last = field.path.at(-1)!
130
+ if (last !== 'speaker' || !VOICE.test(field.before) || row[last] !== field.before || !Array.isArray(field.rawChunkIndices) || !field.rawChunkIndices.length || field.rawChunkIndices.some(raw => !actionRaw.has(raw)) || isMacSpeakerHumanProtected(row)
131
+ || used.has(key) && field.rawChunkIndices.some(raw => selected.get(raw) !== used.get(key))) fail('human field source')
132
+ const raw = field.rawChunkIndices[0], member = byRaw.get(raw)!
133
+ if (field.path[0] === 'chunks') { if (field.rawChunkIndices.length !== 1 || field.path[1] !== member.position || ![3, 5].includes(field.path.length) || field.path.length === 5 && !['segments', 'finalSegments', 'words'].includes(String(field.path[2]))) fail('human compacted field') }
134
+ else if (field.path[0] === 'chunkEntries') { if (field.rawChunkIndices.length !== 1 || field.path[1] !== raw || field.path[2] !== 'chunk' || ![4, 6].includes(field.path.length) || field.path.length === 6 && !['segments', 'finalSegments', 'words'].includes(String(field.path[3]))) fail('human raw field') }
135
+ else if (field.path[0] !== 'batchSegments' || field.path.length !== 5 || field.path[2] !== 'speakerWords') fail('human field scope')
136
+ used.set(key, action.to)
137
+ }
138
+ // A scoped confirmation is never permitted to claim an unmodified root.
139
+ for (const member of action.members) if (!action.fields.some(f => equal(f.path, ['chunks', member.position, 'speaker']))) fail('human root field missing')
140
+ for (const member of action.members) selected.set(member.rawChunkIndex, action.to)
141
+ }
142
+ requireHumanLedgerCoverage(ledger, a)
143
+ return a
144
+ }
145
+
146
+ /** A capture ledger is shared by its copies, but an annotation is bound to one
147
+ * immutable BASE. Never append from a copy that is missing earlier decisions.
148
+ * Only validated action ranges cover these rows; duplicated ids outside their
149
+ * original range cannot confer coverage. Replication is a separate operation. */
150
+ function requireHumanLedgerCoverage(ledger: string, annotation: Annotation | null): void {
151
+ let offset = 0, index = 0
152
+ const actions = annotation?.actions ?? [], lines = ledger.split('\n')
153
+ if (lines.length > 20001) fail('human ledger row budget')
154
+ for (const line of lines) {
155
+ while (index < actions.length && actions[index].ledgerEnd <= offset) index++
156
+ if (line.trim()) {
157
+ const row = JSON.parse(line)
158
+ if (row?.source === PREFIX) {
159
+ const action = actions[index]
160
+ if (!action || offset < action.ledgerStart || offset + line.length > action.ledgerEnd) fail('copy is missing prior human decisions')
161
+ }
162
+ }
163
+ offset += line.length + 1
164
+ }
165
+ }
166
+
167
+ /** Select exact current automatic members. No display-label expansion and no
168
+ * independent nested identity: the b18 source/root agreement remains mandatory. */
169
+ export function macCallHumanScopes(input: { baseJSON: string; markdown: string; ledgerJSONL: string; annotationJSON: string | null; packet: MacSpeakerCallPacket | null; view: MacSpeakerCallView }): MacCallHumanScope[] {
170
+ const { packet, view } = input
171
+ const source = { baseSHA256: hash(input.baseJSON), markdownSHA256: hash(input.markdown), ledgerSHA256: hash(input.ledgerJSONL), annotationSHA256: input.annotationJSON === null ? null : hash(input.annotationJSON) }
172
+ const key = digest({ source, packet: digest(packet), status: view.metadata.status, viewPacket: view.metadata.packetSHA256 })
173
+ if (scopeCache?.key === key) return scopeCache.scopes
174
+ const base = JSON.parse(input.baseJSON), map = mapped(base)
175
+ const result: MacCallHumanScope[] = []
176
+ const finish = () => cacheScopes(key, result)
177
+ const annotation = input.annotationJSON ? validateAnnotation(JSON.parse(input.annotationJSON), input.baseJSON, input.ledgerJSONL) : null
178
+ if (!annotation) requireHumanLedgerCoverage(input.ledgerJSONL, null)
179
+ const protectedRaw = new Set<number>()
180
+ if (annotation) {
181
+ const latestMembers = new Map<number, { member: Member; to: string }>(), latestFields = new Map<string, { field: Field; to: string }>()
182
+ for (const action of annotation.actions) {
183
+ for (const member of action.members) { latestMembers.set(member.rawChunkIndex, { member, to: action.to }); protectedRaw.add(member.rawChunkIndex) }
184
+ for (const field of action.fields) latestFields.set(JSON.stringify(field.path), { field, to: action.to })
185
+ }
186
+ for (const name of new Set([...latestMembers.values()].map(m => m.to))) {
187
+ const members = [...latestMembers.values()].filter(m => m.to === name).map(({ member }) => ({ ...member, before: name, baseBefore: member.baseBefore ?? member.before }))
188
+ const raw = new Set(members.map(m => m.rawChunkIndex)), fields = [...latestFields.values()].filter(f => f.to === name && f.field.rawChunkIndices.every(r => raw.has(r))).map(f => f.field)
189
+ const body = { kind: 'human' as const, reviewBasis: 'human-authored' as const, identityId: `human_${digest({ base: source.baseSHA256, name }).slice(0, 40)}`, displayLabel: name, members, fields, ...source, packetSHA256: null, generation: annotation.annotationSHA256, packetRevision: annotation.revision }
190
+ result.push({ ...body, scopeSHA256: digest(body) })
191
+ }
192
+ }
193
+ if (!packet) return finish()
194
+ const { packetSHA256, ...packetBody } = packet
195
+ if (packetSHA256 !== digest(packetBody)) return finish()
196
+ if (view.metadata.status !== 'applied' || view.metadata.packetSHA256 !== packet.packetSHA256 || packet.admission !== 'qualified' || packet.projectionPolicy !== 'anonymous-estimate' || packet.baseSHA256 !== source.baseSHA256 || packet.ledgerSHA256 !== source.ledgerSHA256) return finish()
197
+ for (const raw of packet.aliasProtectedRawChunkIndices) protectedRaw.add(raw)
198
+ const acousticVoices = new Set(packet.groups.flatMap(g => g.memberVoices))
199
+ // A human may also review an unresolved automatic label. This is NOT an
200
+ // acoustic group/alias: it only presents explicit eligible rows for selection.
201
+ const singletonVoices = [...new Set(map.filter(m => VOICE.test(m.before) && !acousticVoices.has(m.before)).map(m => m.before))]
202
+ const groups = [
203
+ ...packet.groups.map(group => ({ group, displayLabel: packet.displayLabels[group.identityId], reviewBasis: 'accepted-acoustic-group' as const })),
204
+ ...singletonVoices.map(voice => ({ group: { identityId: `review_${digest({ base: source.baseSHA256, voice }).slice(0, 40)}`, memberVoices: [voice] }, displayLabel: voice, reviewBasis: 'automatic-label-only' as const })),
205
+ ]
206
+ const owned = groups.map(({ group, displayLabel, reviewBasis }) => {
207
+ const voices = new Set(group.memberVoices), fields: Field[] = [], members = map.filter(m => !protectedRaw.has(m.rawChunkIndex) && voices.has(m.before) && voices.has(base.chunkEntries[m.rawChunkIndex].chunk.speaker) && base.chunks[m.position].speaker === base.chunkEntries[m.rawChunkIndex].chunk.speaker && !hasMacSpeakerProtectedRows(base.chunks[m.position]) && !hasMacSpeakerProtectedRows(base.chunkEntries[m.rawChunkIndex].chunk))
208
+ const memberRaw = new Set(members.map(m => m.rawChunkIndex))
209
+ for (const member of members) for (const [path, row] of [[['chunks', member.position], base.chunks[member.position]], [['chunkEntries', member.rawChunkIndex, 'chunk'], base.chunkEntries[member.rawChunkIndex].chunk]] as Array<[Path, Doc]>) {
210
+ fields.push({ path: [...path, 'speaker'], before: row.speaker, rawChunkIndices: [member.rawChunkIndex] })
211
+ for (const key of ['segments', 'finalSegments', 'words']) for (const [i, nested] of (row[key] ?? []).entries()) if (voices.has(nested.speaker) && !isMacSpeakerHumanProtected(nested)) fields.push({ path: [...path, key, i, 'speaker'], before: nested.speaker, rawChunkIndices: [member.rawChunkIndex] })
212
+ }
213
+ return { group, displayLabel, reviewBasis, members, memberRaw, fields }
214
+ }).filter(group => group.members.length > 0)
215
+ const byVoice = new Map(owned.flatMap(group => group.group.memberVoices.map(voice => [voice, group] as const)))
216
+ // Bind each word once in its exact concatenated-RAW clock. ASR words need
217
+ // not be sorted, so locate the first intersected source with binary search.
218
+ if (owned.length) for (const [si, segment] of (base.batchSegments ?? []).entries()) {
219
+ const clock: Array<{ raw: number; lo: number; hi: number }> = []; let offset = 0
220
+ for (let raw = segment.startChunkIdx; raw <= segment.endChunkIdx; raw++) { const r = packet.rawBindings[raw]; if (!r) fail('batch binding'); const n = r.endSample - r.startSample; clock.push({ raw, lo: offset, hi: offset + n }); offset += n }
221
+ for (const [wi, word] of (segment.speakerWords ?? []).entries()) {
222
+ const group = byVoice.get(word.speaker)
223
+ if (!group || isMacSpeakerHumanProtected(word)) continue
224
+ const lo = Math.round(word.start * 16000), hi = Math.round(word.end * 16000)
225
+ if (!Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || lo < 0 || hi <= lo || hi > offset) continue
226
+ let left = 0, right = clock.length
227
+ while (left < right) { const mid = (left + right) >>> 1; if (clock[mid].hi <= lo) left = mid + 1; else right = mid }
228
+ const raw: number[] = []
229
+ for (let i = left; i < clock.length && clock[i].lo < hi; i++) raw.push(clock[i].raw)
230
+ if (raw.length && raw.every(r => group.memberRaw.has(r))) group.fields.push({ path: ['batchSegments', si, 'speakerWords', wi, 'speaker'], before: word.speaker, rawChunkIndices: raw })
231
+ }
232
+ }
233
+ for (const { group, displayLabel, reviewBasis, members, fields } of owned) {
234
+ const body = { kind: 'automatic' as const, reviewBasis, identityId: group.identityId, displayLabel, members, fields, ...source, packetSHA256: packet.packetSHA256, generation: packet.generation, packetRevision: packet.revision }
235
+ if (fields.length > 40000) fail('field budget')
236
+ result.push({ ...body, scopeSHA256: digest(body) })
237
+ }
238
+ return finish()
239
+ }
240
+
241
+ export function applyMacCallHumanAnnotation(baseJSON: string, ledger: string, annotationJSON: string | null, view: MacSpeakerCallView): MacSpeakerCallView {
242
+ if (annotationJSON === null) return view
243
+ const annotation = validateAnnotation(JSON.parse(annotationJSON), baseJSON, ledger), doc = structuredClone(view.doc)
244
+ const changedRaw = new Set<number>(), changedWords = new Set<string>(), activeNames = new Map<string, string>()
245
+ for (const action of annotation.actions) {
246
+ action.members.forEach(m => changedRaw.add(m.rawChunkIndex))
247
+ for (const field of action.fields) { const row = at(doc, field.path); row[field.path.at(-1)!] = action.to; activeNames.set(JSON.stringify(field.path), action.to); if (field.path[0] === 'batchSegments') changedWords.add(JSON.stringify(field.path)) }
248
+ }
249
+ doc.speakers = [...new Set([...(doc.speakers ?? []), ...activeNames.values()])]
250
+ return { ...view, doc, metadata: { ...view.metadata, status: 'applied', reason: 'source-bound-human-annotation', changedChunks: view.metadata.changedChunks + changedRaw.size, changedWords: view.metadata.changedWords + changedWords.size, humanAnnotation: { revision: annotation.revision, annotationSHA256: annotation.annotationSHA256, members: changedRaw.size } } }
251
+ }
252
+
253
+ interface Read { text: string; hash: string; stamp: string }
254
+ const stamp = (s: Stats) => [s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs].join(':')
255
+ export function readMacCallHumanFile(path: string, max: number, missing = false): Read | null {
256
+ let fd: number
257
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) } catch (e) { if (missing && (e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e }
258
+ try {
259
+ const before = fstatSync(fd); if (!before.isFile() || before.size > max) fail('unsafe file')
260
+ const bytes = Buffer.alloc(before.size + 1); let n = 0
261
+ while (n < bytes.length) { const got = readSync(fd, bytes, n, bytes.length - n, null); if (!got) break; n += got }
262
+ const after = fstatSync(fd), current = lstatSync(path)
263
+ if (n !== before.size || stamp(before) !== stamp(after) || stamp(after) !== stamp(current) || current.isSymbolicLink()) fail('file read drift')
264
+ const contents = bytes.subarray(0, n), text = contents.toString('utf8')
265
+ if (!Buffer.from(text, 'utf8').equals(contents)) fail('non-UTF8 source')
266
+ return { text, hash: hash(text), stamp: stamp(after) }
267
+ } finally { closeSync(fd) }
268
+ }
269
+ function directory(path: string) { const dir = dirname(path); if (realpathSync(dir) !== dir || !lstatSync(dir).isDirectory()) fail('canonical parent required'); return dir }
270
+ function syncDir(path: string) { const fd = openSync(directory(path), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { fsyncSync(fd) } finally { closeSync(fd) } }
271
+ function atomic(path: string, text: string, before: () => void) {
272
+ directory(path); const temp = `${path}.${randomUUID()}.tmp`
273
+ const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
274
+ try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
275
+ try { before(); renameSync(temp, path); syncDir(path) } finally { if (existsSync(temp)) unlinkSync(temp) }
276
+ }
277
+ function acquire(path: string, journal: string, recover: boolean) {
278
+ const old = readMacCallHumanFile(path, 4096, true)
279
+ if (old) {
280
+ if (!recover) fail('capture locked')
281
+ const owner = JSON.parse(old.text)
282
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.kind !== PREFIX || owner.directory !== journal) fail('foreign lock')
283
+ try { process.kill(owner.pid, 0); fail('capture lock owner alive') } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e }
284
+ if (readMacCallHumanFile(path, 4096)!.hash !== old.hash) fail('lock drift')
285
+ unlinkSync(path); syncDir(path)
286
+ }
287
+ directory(path); const text = JSON.stringify({ pid: process.pid, token: randomUUID(), kind: PREFIX, directory: journal }), fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
288
+ try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
289
+ syncDir(path)
290
+ const assertOwned = () => { if (readMacCallHumanFile(path, 4096, true)?.hash !== hash(text)) fail('lock drift') }
291
+ return { assertOwned, release: () => { assertOwned(); unlinkSync(path); syncDir(path) } }
292
+ }
293
+ interface Pin { path: string; hash: string | null; maximum: number }
294
+ interface Journal { version: 1; policy: typeof PREFIX; captureId: string; phase: 'prepared' | 'complete'; pins: Pin[]; images: Array<{ path: string; before: string | null; after: string }>; journalSHA256: string }
295
+ function sealJournal(body: Omit<Journal, 'journalSHA256'>): Journal { return { ...body, journalSHA256: digest(body) } }
296
+ function runJournal(files: MacCallHumanFiles, captureId: string, journal: Journal, expectedJournal: string, afterWrite?: (index: number) => void, assertOwned: () => void = () => {}) {
297
+ const paths = macCallHumanPaths(files, captureId), { journalSHA256, ...body } = journal
298
+ if (journal.version !== 1 || journal.policy !== PREFIX || journal.captureId !== captureId || journalSHA256 !== digest(body) || journal.images.length !== 2 || journal.images[0].path !== paths.ledger || journal.images[1].path !== paths.annotation || !['prepared', 'complete'].includes(journal.phase) || !equal(journal.pins.map(p => p.path), [files.sidecarPath, files.meetingPath, paths.packet])) fail('journal scope')
299
+ if (!equal(journal.pins.map(p => p.maximum), [32 * 1024 * 1024, 16 * 1024 * 1024, 16 * 1024 * 1024]) || journal.pins.some(p => p.hash !== null && !SHA.test(p.hash)) || journal.images.some(image => (image.before !== null && typeof image.before !== 'string') || typeof image.after !== 'string' || Buffer.byteLength(image.before ?? '') > 16 * 1024 * 1024 || Buffer.byteLength(image.after) > 16 * 1024 * 1024)) fail('journal bounds')
300
+ const fence = () => {
301
+ assertOwned()
302
+ if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== expectedJournal) fail('journal changed')
303
+ for (const pin of journal.pins) if ((readMacCallHumanFile(pin.path, pin.maximum, pin.hash === null)?.hash ?? null) !== pin.hash) fail('source changed')
304
+ return journal.images.map(image => { const actual = readMacCallHumanFile(image.path, 16 * 1024 * 1024, true)?.text ?? null; if (actual !== image.before && actual !== image.after) fail('later human edit'); return actual })
305
+ }
306
+ if (journal.phase === 'complete') {
307
+ assertOwned()
308
+ if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== expectedJournal || journal.images.some(image => readMacCallHumanFile(image.path, 16 * 1024 * 1024, true)?.text !== image.after)) fail('completed annotation changed')
309
+ return // Archival human decision: refreshed automatic packets are allowed.
310
+ }
311
+ let current = fence()
312
+ for (const [index, image] of journal.images.entries()) {
313
+ fence()
314
+ if (current[index] !== image.after) atomic(image.path, image.after, () => { const checked = fence(); for (let i = 0; i < index; i++) if (checked[i] !== journal.images[i].after) fail('earlier publication changed') })
315
+ afterWrite?.(index)
316
+ current = fence()
317
+ for (let i = 0; i <= index; i++) if (current[i] !== journal.images[i].after) fail('publication changed')
318
+ }
319
+ const completed = `${JSON.stringify(sealJournal({ ...body, phase: 'complete' }))}\n`
320
+ atomic(paths.journal, completed, () => { const checked = fence(); if (checked.some((v, i) => v !== journal.images[i].after)) fail('postimage changed') })
321
+ if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== completed) fail('journal readback changed')
322
+ }
323
+ export function recoverMacCallHumanAnnotation(files: MacCallHumanFiles, captureId: string, afterWrite?: (index: number) => void): void {
324
+ const paths = macCallHumanPaths(files, captureId), journal = readMacCallHumanFile(paths.journal, 64 * 1024 * 1024, true)
325
+ if (!journal) return
326
+ const guard = acquire(paths.lock, paths.journal, true)
327
+ try { runJournal(files, captureId, JSON.parse(journal.text), journal.text, afterWrite, guard.assertOwned) } finally { guard.release() }
328
+ }
329
+ export function commitMacCallHumanAnnotation(input: { files: MacCallHumanFiles; captureId: string; scope: MacCallHumanScope; selectedRawChunkIndices: number[]; to: string; afterWrite?: (index: number) => void }): { correctionId: string; members: number; fields: number } {
330
+ if (!isMacCallHumanName(input.to) || !Array.isArray(input.selectedRawChunkIndices) || !input.selectedRawChunkIndices.length || input.selectedRawChunkIndices.length > 6000 || new Set(input.selectedRawChunkIndices).size !== input.selectedRawChunkIndices.length) fail('explicit name and source selection required')
331
+ const paths = macCallHumanPaths(input.files, input.captureId)
332
+ mkdirSync(dirname(paths.ledger), { recursive: true, mode: 0o700 }); directory(paths.ledger)
333
+ const guard = acquire(paths.lock, paths.journal, false)
334
+ try {
335
+ const base = readMacCallHumanFile(input.files.sidecarPath, 32 * 1024 * 1024)!, md = readMacCallHumanFile(input.files.meetingPath, 16 * 1024 * 1024)!, ledger = readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true), packet = readMacCallHumanFile(paths.packet, 16 * 1024 * 1024, true), prior = readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)
336
+ const scope = input.scope, { scopeSHA256, ...scopeBody } = scope
337
+ const parsedPacket = scope.kind === 'automatic' && packet ? JSON.parse(packet.text) as MacSpeakerCallPacket : null
338
+ const { packetSHA256, ...packetBody } = parsedPacket ?? { packetSHA256: null }
339
+ if (scopeSHA256 !== digest(scopeBody) || base.hash !== scope.baseSHA256 || md.hash !== scope.markdownSHA256 || hash(ledger?.text ?? '') !== scope.ledgerSHA256 || prior?.hash !== (scope.annotationSHA256 ?? undefined) || !['automatic', 'human'].includes(scope.kind) || scope.kind === 'automatic' && (packetSHA256 !== scope.packetSHA256 || packetSHA256 !== digest(packetBody) || parsedPacket?.generation !== scope.generation || parsedPacket?.revision !== scope.packetRevision)) fail('reviewed source changed')
340
+ const existing = prior ? validateAnnotation(JSON.parse(prior.text), base.text, ledger?.text ?? '') : null
341
+ if (!existing) requireHumanLedgerCoverage(ledger?.text ?? '', null)
342
+ if (scope.kind === 'human' && (!existing || existing.annotationSHA256 !== scope.generation || existing.revision !== scope.packetRevision)) fail('reviewed human annotation changed')
343
+ const selected = new Set(input.selectedRawChunkIndices), members = scope.members.filter(m => selected.has(m.rawChunkIndex))
344
+ if (members.length !== selected.size) fail('selection outside reviewed group')
345
+ const fields = selectedFields(scope, selected)
346
+ const fieldScopeSHA256 = digest({ identityId: scope.identityId, members, fields })
347
+ const id = `${PREFIX}:${randomUUID()}`, atTime = new Date().toISOString(), groups = new Map<string, number[]>()
348
+ for (const m of members) { const positions = groups.get(m.before) ?? []; positions.push(m.position); groups.set(m.before, positions) }
349
+ const rows = [...groups].flatMap(([from, chunks], index) => ['intent', 'applied', 'confirmed-chunks'].map(phase => ({ id: `${id}:${index}`, batchId: id, phase, at: atTime, from, to: input.to, chunks, scope: 'meeting', source: PREFIX, fieldScopeSHA256 })))
350
+ const beforeLedger = ledger?.text ?? '', afterLedger = beforeLedger + (beforeLedger && !beforeLedger.endsWith('\n') ? '\n' : '') + rows.map(r => JSON.stringify(r)).join('\n') + '\n'
351
+ const action: Action = { id, at: atTime, identityId: scope.identityId, to: input.to, scopeSHA256, members, fields, fieldScopeSHA256, ledgerStart: beforeLedger.length, ledgerEnd: afterLedger.length, ledgerBeforeSHA256: hash(beforeLedger), ledgerAfterSHA256: hash(afterLedger) }
352
+ const annotation = seal({ version: 1, policy: PREFIX, captureId: input.captureId, revision: (existing?.revision ?? 0) + 1, baseSHA256: base.hash, ledgerSHA256: hash(afterLedger), actions: [...(existing?.actions ?? []), action] }), afterAnnotation = `${JSON.stringify(annotation)}\n`
353
+ validateAnnotation(annotation, base.text, afterLedger)
354
+ const humanDoc = structuredClone(JSON.parse(base.text))
355
+ for (const a of annotation.actions) for (const field of a.fields) at(humanDoc, field.path)[field.path.at(-1)!] = a.to
356
+ renderMarkdown(md.text, boundWords(JSON.parse(base.text), humanDoc))
357
+ if (Buffer.byteLength(afterLedger) > 8 * 1024 * 1024 || Buffer.byteLength(afterAnnotation) > 16 * 1024 * 1024) fail('annotation byte budget')
358
+ const oldJournal = readMacCallHumanFile(paths.journal, 64 * 1024 * 1024, true)
359
+ if (oldJournal && JSON.parse(oldJournal.text).phase !== 'complete') fail('annotation recovery required')
360
+ const pins = [{ path: input.files.sidecarPath, hash: base.hash, maximum: 32 * 1024 * 1024 }, { path: input.files.meetingPath, hash: md.hash, maximum: 16 * 1024 * 1024 }, { path: paths.packet, hash: packet?.hash ?? null, maximum: 16 * 1024 * 1024 }]
361
+ const journal = sealJournal({ version: 1, policy: PREFIX, captureId: input.captureId, phase: 'prepared', pins, images: [{ path: paths.ledger, before: ledger?.text ?? null, after: afterLedger }, { path: paths.annotation, before: prior?.text ?? null, after: afterAnnotation }] }), journalText = `${JSON.stringify(journal)}\n`
362
+ atomic(paths.journal, journalText, () => {
363
+ guard.assertOwned()
364
+ for (const pin of pins) if ((readMacCallHumanFile(pin.path, pin.maximum, pin.hash === null)?.hash ?? null) !== pin.hash) fail('source changed')
365
+ if ((readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true)?.text ?? null) !== (ledger?.text ?? null) || (readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)?.text ?? null) !== (prior?.text ?? null) || (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024, true)?.text ?? null) !== (oldJournal?.text ?? null)) fail('publication precondition changed')
366
+ })
367
+ if (readMacCallHumanFile(paths.journal, 64 * 1024 * 1024)!.text !== journalText) fail('journal readback changed')
368
+ runJournal(input.files, input.captureId, journal, journalText, input.afterWrite, guard.assertOwned)
369
+ return { correctionId: id, members: members.length, fields: fields.length }
370
+ } finally { guard.release() }
371
+ }