@gotcos/glasses-server 6.94.2 → 6.94.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/package.json +2 -1
- package/server/index.ts +6 -0
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/held-naming-batches.ts +8 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/mac-voice-assignment.ts +2 -1
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +9 -8
- package/server/lib/session-find-ask.ts +281 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +2 -1
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/meeting.ts +11 -10
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 6.94.3 - Search sessions by meaning, and accented speaker names
|
|
2
|
+
|
|
3
|
+
2026-11-01. Pairs with COS Control 0.5.363. Control 0.5.361 and older keep working and never call the new route.
|
|
4
|
+
|
|
5
|
+
**New, and off until you say yes in Control.**
|
|
6
|
+
|
|
7
|
+
- `POST /api/agent-sessions/find` asks your decision model (Jev by default, SubQ if chosen) which recent sessions a few typed words refer to, and returns up to 5 rows in order of likelihood. It answers only for the provider you consented to in Control, and refuses before reading any session otherwise.
|
|
8
|
+
- Each search sends the typed words, plus the title and first 150 characters of the first prompt from up to 254 recent sessions, then up to 508 if the first pass finds nothing strong. Emails, URLs, links to an IP address or localhost, home-folder paths (not every file path) and key-shaped text are removed first. Names and other details are not. SubQ keeps what it is sent. Archived, keyed and scheduled sessions are left out.
|
|
9
|
+
- Its own daily caps (3M tokens per provider), breaker and `COS_SESSION_FIND=0` switch. `GET /api/decision-provider` adds `capabilities.sessionFind`.
|
|
10
|
+
|
|
11
|
+
**Default-on, no setting.**
|
|
12
|
+
|
|
13
|
+
- Accented speaker names now match in either Unicode form. "Yes, this is Brigitta Pólya" failed with "No chunk in this meeting is labelled" because Control's helper hands names over decomposed. Meeting and voice routes read names as NFC, compare both sides, and store new names as NFC. Merge, delete-person, calibration, the rename fan-out and the embeddings manager match a stored name in either form, and a meeting record id matches in either form too (a decomposed id from Control's helper opens the same record). Merge refuses if any stored copy of a name is below the similarity floor. Names already saved are not rewritten.
|
|
14
|
+
|
|
15
|
+
No other route changes. The session list and search are unchanged.
|
|
16
|
+
|
|
1
17
|
## 6.94.2 - Scoped profile matches for saved Mac recordings
|
|
2
18
|
|
|
3
19
|
2026-10-28. Compatible with COS Control 0.5.352; no Control or glasses package change required.
|
package/README.md
CHANGED
|
@@ -699,6 +699,10 @@ flag a Mac rename is text only. `GET /api/health` reports `capabilities.macLiveV
|
|
|
699
699
|
The saved meeting is relabeled by the whole-meeting pass. Live numbers are kept;
|
|
700
700
|
voices the live path did not prove are numbered above them at save.
|
|
701
701
|
|
|
702
|
+
### Session find by meaning (6.94.3)
|
|
703
|
+
|
|
704
|
+
`POST /api/agent-sessions/find` backs ⌘K in COS Control 0.5.363. It is off until the person says yes for one provider in Control. See `docs/decision-providers.md` for what leaves the Mac, the caps and `COS_SESSION_FIND=0`.
|
|
705
|
+
|
|
702
706
|
### Saved Mac profile reconciliation (6.94.2)
|
|
703
707
|
|
|
704
708
|
After transcription and diarization, eligible Mac recordings receive a bounded
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.94.
|
|
3
|
+
"version": "6.94.3",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
53
53
|
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
54
54
|
"!server/scripts/work-search-mutation-gate.mjs",
|
|
55
|
+
"!server/scripts/session-find-redact-mutation-gate.mjs",
|
|
55
56
|
"!server/scripts/fact-check-mutation-gate.mjs",
|
|
56
57
|
"!server/scripts/stale-session-mutation-gate.mjs",
|
|
57
58
|
"!server/scripts/autonomous-save-mutation-gate.mjs",
|
package/server/index.ts
CHANGED
|
@@ -159,6 +159,8 @@ import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.
|
|
|
159
159
|
import { createSubqKeyRouter } from './routes/subq-key.js'
|
|
160
160
|
import { createDecisionProviderRouter } from './routes/decision-provider.js'
|
|
161
161
|
import { createJevRouter } from './routes/jev.js'
|
|
162
|
+
import { createSessionFindRouter } from './routes/session-find.js'
|
|
163
|
+
import { isSessionFindPost } from './lib/session-find.js'
|
|
162
164
|
import { createWorkChatRouter } from './routes/work-chat.js'
|
|
163
165
|
import { createWorkOrchestratorRouter } from './routes/work-orchestrator.js'
|
|
164
166
|
import { createWorkLensRoundsRouter } from './routes/work-lens-rounds.js'
|
|
@@ -394,6 +396,8 @@ app.use('/api', (req, res, next) => {
|
|
|
394
396
|
// 6.65.0: a POST that only reads (Work search: a board read and one Jev question) is not a mutation. See
|
|
395
397
|
// lib/work-search.ts isReadOnlyApiPost for why holding the lease would hurt and why skipping it is safe.
|
|
396
398
|
if (isReadOnlyApiPost(req.method, req.path)) return next()
|
|
399
|
+
// Session find (Control 0.5.363) reads the session store and asks a model; it writes only its own token ledger. See lib/session-find.ts.
|
|
400
|
+
if (isSessionFindPost(req.method, req.path)) return next()
|
|
397
401
|
// V2 video original chunks are bounded to the advertised session size (1 MiB
|
|
398
402
|
// on new sessions, 256 KiB on leftover drafts) and commit through the upload
|
|
399
403
|
// registry's own generation lock. Holding the global mutation lease while
|
|
@@ -856,6 +860,8 @@ app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workRe
|
|
|
856
860
|
app.use('/api', createDecisionProviderRouter())
|
|
857
861
|
app.use('/api', createSubqKeyRouter())
|
|
858
862
|
app.use('/api', createWorkSearchRouter())
|
|
863
|
+
// Find a session by meaning (Control 0.5.363): consent per provider, its own ledgers and breakers (lib/session-find.ts).
|
|
864
|
+
app.use('/api', createSessionFindRouter())
|
|
859
865
|
app.use('/api', createWorkChatRouter())
|
|
860
866
|
// 6.83.0 Work orchestrator: Control's Work snapshot, the planner input and plan usage (docs/work-orchestrator.md).
|
|
861
867
|
app.use('/api', createWorkOrchestratorRouter())
|
|
@@ -154,11 +154,13 @@ export function resetSubqDecisionClients(): void { configuredSubqClients.forEach
|
|
|
154
154
|
/** A SubQ client built outside createDecisionClient (live cues, 6.76.2) still starts clean when a new key is saved. */
|
|
155
155
|
export function trackSubqDecisionClient(client: SubqDecisionClient): SubqDecisionClient { configuredSubqClients.add(client); return client }
|
|
156
156
|
/** Snapshot selection per workflow request. Separate clients and cache identities prevent stale cross-provider answers. */
|
|
157
|
-
export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env
|
|
157
|
+
export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env,
|
|
158
|
+
/** Optional: a SubQ ledger file and daily cap of this feature's own. Without it SubQ uses `subq-<useCase>-usage.json` and COS_SUBQ_DAILY_TOKENS. */
|
|
159
|
+
subq: { usageFile?: string; cap?: () => number } = {}): DecisionClient {
|
|
158
160
|
const clients: Partial<Record<DecisionProvider, DecisionClient>> = {}
|
|
159
161
|
const snapshot = (): DecisionClient => {
|
|
160
162
|
const selected = selectedDecisionProvider(useCase, env)
|
|
161
|
-
if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), dataPath(`subq-${useCase}-usage.json`)) : {
|
|
163
|
+
if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), subq.usageFile ?? dataPath(`subq-${useCase}-usage.json`), undefined, subq.cap) : {
|
|
162
164
|
provider: 'jev', async ask(state, questions, estimate, options) {
|
|
163
165
|
if (options?.images?.length) throw new DecisionProviderError('decision_images_unsupported')
|
|
164
166
|
return { ...await jev.ask(state, questions, estimate, options), provider: 'jev' }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Durable held naming: server-stored preview, per-copy receipts, explicit recovery. */
|
|
2
|
+
import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
2
3
|
import { createHash, randomUUID } from 'node:crypto'
|
|
3
4
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs'
|
|
4
5
|
import { dirname, join, resolve } from 'node:path'
|
|
@@ -27,12 +28,12 @@ export function resolveStoredName(name: string): {name:string;owner:boolean} {
|
|
|
27
28
|
if (stored.length > 1) throw new HeldGroupError(400,'ambiguous_name','Multiple stored voices have that spelling. Rename the duplicate profiles first.',{names:stored.map(p=>p.name)})
|
|
28
29
|
const owner = getOwnerSpeakerLabel()
|
|
29
30
|
const isOwner = foldName(owner) === foldName(name.trim()) || (stored[0] && foldName(stored[0].name) === foldName(owner))
|
|
30
|
-
return {name:isOwner ? owner : stored[0]?.name ?? name.trim(),owner:!!isOwner}
|
|
31
|
+
return {name:isOwner ? owner : stored[0]?.name ?? nfc(name.trim()),owner:!!isOwner}
|
|
31
32
|
}
|
|
32
33
|
function voiceVersion(name: string) {
|
|
33
34
|
const path = dataPath('voice-profiles.json')
|
|
34
35
|
const profiles = readVoiceProfiles().profiles
|
|
35
|
-
return {storeMtime:existsSync(path)?statSync(path).mtimeMs:0,profileCount:profiles.length,targetSamples:profiles.find(p=>p.name
|
|
36
|
+
return {storeMtime:existsSync(path)?statSync(path).mtimeMs:0,profileCount:profiles.length,targetSamples:profiles.find(p=>sameSpeakerName(p.name,name))?.embeddings.length ?? 0}
|
|
36
37
|
}
|
|
37
38
|
function evidenceVersion(refs:HeldSampleRef[]) {
|
|
38
39
|
return hash([...new Set(refs.map(r=>r.sessionId))].sort().map(sessionId=>({sessionId,rows:readChunkEmbeddings(sessionId).rows.map(r=>({i:r.i,embedding:Array.from(r.embedding)})),cache:existsSync(dataPath('held-voice-embeddings',`${sessionId}.json`))?readFileSync(dataPath('held-voice-embeddings',`${sessionId}.json`),'utf8'):''})))
|
|
@@ -472,7 +473,7 @@ export async function undoHeldNaming(batchId:string,confirm:boolean){
|
|
|
472
473
|
* enrolment/audio deletion off. Dry run neither records a preview nor writes a ledger. */
|
|
473
474
|
export async function renameMeetingSpeaker(sessionId:string,from:string,to:string,options:{apply?:boolean;operationsDir?:string;expectedSidecarPath?:string}={}) : Promise<{copies:Array<{sidecarPath:string;meetingPath:string;labels:number;transcript:number;attendees:number;unresolvedTurns:number}>;batchId?:string;error?:string}> {
|
|
474
475
|
if(options.operationsDir && resolveCosOperationsDir()!==realpathSync(options.operationsDir)) throw new Error('Operations root differs from the requested fan-out root')
|
|
475
|
-
if(from
|
|
476
|
+
if(sameSpeakerName(from,to)||!from.trim()||!to.trim())return{copies:[]}
|
|
476
477
|
if(options.apply&&mutationBusy)throw new Error('Another naming is running; retry')
|
|
477
478
|
let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
|
|
478
479
|
let batch:BatchRecord|undefined
|
|
@@ -486,22 +487,22 @@ export async function renameMeetingSpeaker(sessionId:string,from:string,to:strin
|
|
|
486
487
|
if(options.expectedSidecarPath && (!operations || realpathSync(operations.sidecarPath)!==realpathSync(options.expectedSidecarPath)))throw new Error('Session resolves to a different sidecar; duplicate or archived copy')
|
|
487
488
|
const maps=copies.map(c=>validatedRawMap(c.doc))
|
|
488
489
|
if(maps.some(map=>hash(map)!==hash(maps[0])))throw new Error('Meeting copies disagree on raw mapping')
|
|
489
|
-
const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>c.speaker
|
|
490
|
+
const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>sameSpeakerName(c.speaker,from)?[i]:[])
|
|
490
491
|
// Repair historical chunks-only renames too: HQ words still carry `from`
|
|
491
492
|
// even when the compacted chunk already says `to` (the 6.27.10 trap).
|
|
492
493
|
for(const c of copies)for(const segment of c.doc.batchSegments??[])for(const word of segment.speakerWords??[]){
|
|
493
|
-
if(word.speaker
|
|
494
|
+
if(!sameSpeakerName(word.speaker,from))continue
|
|
494
495
|
const absolute=Number(segment.startElapsed??0)+Number(word.start)*1000
|
|
495
496
|
let position=-1,distance=Infinity
|
|
496
497
|
const raw=validatedRawMap(c.doc)
|
|
497
498
|
raw.forEach((index,i)=>{if(index<segment.startChunkIdx||index>segment.endChunkIdx)return;const d=Math.abs(Number(c.doc.chunks[i].elapsed)-absolute);if(d<distance){distance=d;position=i}})
|
|
498
499
|
if(distance>3500||position<0)throw new Error('Old HQ speaker has no safe raw chunk mapping')
|
|
499
|
-
if(![from,to].
|
|
500
|
+
if(![from,to].some(n=>sameSpeakerName(n,c.doc.chunks[position].speaker)))throw new Error('HQ speaker conflicts with an unrelated named chunk')
|
|
500
501
|
if(!positions.includes(position))positions.push(position)
|
|
501
502
|
}
|
|
502
503
|
positions.sort((a,b)=>a-b)
|
|
503
504
|
if(!positions.length)return{copies:[]}
|
|
504
|
-
if(copies.some(c=>positions.some((i:number)=>![from,to].
|
|
505
|
+
if(copies.some(c=>positions.some((i:number)=>![from,to].some(n=>sameSpeakerName(n,c.doc.chunks[i]?.speaker)))))throw new Error('Meeting copies disagree on prior speaker labels')
|
|
505
506
|
const batchId=randomUUID()
|
|
506
507
|
const edits=copies.map(c=>relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,positions,to,batchId))
|
|
507
508
|
const results=copies.map((c,i)=>({sidecarPath:c.sidecarPath,meetingPath:c.meetingPath,labels:positions.length,transcript:edits[i].transcript,attendees:edits[i].attendees,unresolvedTurns:edits[i].unresolvedTurns}))
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
greedyDiversitySelect,
|
|
63
63
|
pairwiseSimilarityMatrix,
|
|
64
64
|
} from './voice-enrolment-selection.js'
|
|
65
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
65
66
|
|
|
66
67
|
/** Where decoded-on-demand vectors are kept. One JSON file per held session. */
|
|
67
68
|
export const HELD_EMBEDDING_CACHE_DIR = 'held-voice-embeddings'
|
|
@@ -432,12 +433,12 @@ export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[]
|
|
|
432
433
|
if (!best || candidate.score > best.score) best = candidate
|
|
433
434
|
}
|
|
434
435
|
if (!best || best.anchor === null) return null
|
|
435
|
-
const winner = profiles.find(p => p.name
|
|
436
|
+
const winner = profiles.find(p => sameSpeakerName(p.name, best!.name))!
|
|
436
437
|
// Near-duplicate profile pairs represent an existing identity ambiguity, not
|
|
437
438
|
// independent competing acoustic evidence. Do not let that pair erase the
|
|
438
439
|
// margin against genuinely different voices. Never merge or rename here.
|
|
439
440
|
const runnerUp = candidates.filter(c => c.name !== best!.name).filter(c => {
|
|
440
|
-
const other = profiles.find(p => p.name
|
|
441
|
+
const other = profiles.find(p => sameSpeakerName(p.name, c.name))!
|
|
441
442
|
return profileSimilarity(winner, other) < HELD_NEAR_PROFILE_SIMILARITY
|
|
442
443
|
}).reduce((score, c) => Math.max(score, c.score), 0)
|
|
443
444
|
const margin = best.score - runnerUp
|
|
@@ -754,7 +755,7 @@ export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dry
|
|
|
754
755
|
const core = expand(coreReps)
|
|
755
756
|
const leftBehind = expand(reps.filter(i => !coreSet.has(i))).map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex })).sort(compareRefs)
|
|
756
757
|
|
|
757
|
-
const existing = readVoiceProfiles().profiles.find(p => p.name
|
|
758
|
+
const existing = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, name))
|
|
758
759
|
// A primitive snapshot, taken BEFORE the loop: the store hands back its live
|
|
759
760
|
// `embeddings` array by reference, and enrolment appends to that same array
|
|
760
761
|
// until the first write invalidates the cache. Reading `.length` afterwards
|
|
@@ -826,7 +827,7 @@ export function projectHeldEnrollment(name: string, refs: HeldSampleRef[]): {
|
|
|
826
827
|
const profiles = readVoiceProfiles().profiles.map(profile => ({
|
|
827
828
|
...profile, embeddings: profile.embeddings.map(row => [...row]), sources: [...(profile.sources ?? [])],
|
|
828
829
|
}))
|
|
829
|
-
let target = profiles.find(profile => profile.name
|
|
830
|
+
let target = profiles.find(profile => sameSpeakerName(profile.name, name))
|
|
830
831
|
if (!target) { target = { name, embeddings: [], sources: [] }; profiles.push(target) }
|
|
831
832
|
const selectedMembers: HeldSampleRef[] = []
|
|
832
833
|
for (const embedding of selected) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// every check below is one failed stat. Reading never creates. Remove all keys (plan 12) keeps
|
|
11
11
|
// this file on purpose: a keyed transcript stays refused after the keys are gone. A manual
|
|
12
12
|
// `claude --resume` in a terminal is documented, not blocked.
|
|
13
|
-
import { statSync } from 'node:fs'
|
|
13
|
+
import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs'
|
|
14
14
|
import { dataPath } from './data-dir.js'
|
|
15
15
|
import { readPrivateJsonFile, writePrivateJsonFile } from './key-store.js'
|
|
16
16
|
import { registerKeyLanesStatusProvider } from './key-lanes.js'
|
|
@@ -83,6 +83,26 @@ export function listKeyedSessions(): KeyedSessionRecord[] {
|
|
|
83
83
|
return [...byId.values()]
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The same records, read so that a damaged file cannot pass for an empty one. listKeyedSessions reads a corrupt or unreadable
|
|
88
|
+
* file as "none", which is right for the places that refuse a keyed session (they fail open on a file nobody can read) and wrong for
|
|
89
|
+
* a place that must NOT send keyed text anywhere: a corrupt file sent keyed session text to a model in a probe. A missing file is
|
|
90
|
+
* none; a file that exists and is not the store's shape throws. Session find uses this; nothing else does.
|
|
91
|
+
*/
|
|
92
|
+
export function listKeyedSessionsStrict(): KeyedSessionRecord[] {
|
|
93
|
+
const byId = new Map<string, KeyedSessionRecord>()
|
|
94
|
+
const file = keyedSessionsPath()
|
|
95
|
+
if (existsSync(file)) {
|
|
96
|
+
const st = lstatSync(file)
|
|
97
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error('keyed sessions file is not a plain file')
|
|
98
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<KeyedSessionsFile> | null
|
|
99
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.sessions) || !parsed.sessions.every(isRecord)) throw new Error('keyed sessions file is not readable')
|
|
100
|
+
for (const r of parsed.sessions) byId.set(r.id, r)
|
|
101
|
+
}
|
|
102
|
+
for (const r of memory.values()) if (!byId.has(r.id)) byId.set(r.id, r)
|
|
103
|
+
return [...byId.values()]
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
/** True once anything was ever recorded. The cheap short-circuit every refusing site starts with. */
|
|
87
107
|
export function anyKeyedSessions(): boolean {
|
|
88
108
|
return memory.size > 0 || fileRecords().length > 0
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
greedyDiversitySelect,
|
|
78
78
|
} from './voice-enrolment-selection.js'
|
|
79
79
|
import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
|
|
80
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
80
81
|
|
|
81
82
|
/** A turn shorter than this is not sliced out of a mixed chunk. */
|
|
82
83
|
export const MAC_ASSIGN_MIN_SLICE_SEC = 3.0
|
|
@@ -239,7 +240,7 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
|
|
|
239
240
|
if (isPlaceholderLabel(to)) return none('unknown_profile')
|
|
240
241
|
if (input.ownerLabel && sameName(to, input.ownerLabel)) return none('owner_profile')
|
|
241
242
|
let profile: { name: string; embeddings: number[][]; sources?: string[] } | undefined
|
|
242
|
-
try { profile = readVoiceProfiles().profiles.find(p => p.name
|
|
243
|
+
try { profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, to)) } catch { return none('store_unavailable') }
|
|
243
244
|
if (!profile) return none('unknown_profile')
|
|
244
245
|
if (!chunkEmbeddingsEnabled()) return none('disabled')
|
|
245
246
|
if (!isEmbeddingAvailable()) return none('store_unavailable')
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
28
28
|
import { join, resolve } from 'node:path'
|
|
29
29
|
import { dataPath } from './data-dir.js'
|
|
30
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
30
31
|
|
|
31
32
|
export const CORRECTIONS_DIR = 'meeting-corrections'
|
|
32
33
|
|
|
@@ -268,7 +269,7 @@ export function currentLabelFor(sessionId: string, originalLabel: string): strin
|
|
|
268
269
|
for (let hop = 0; hop < applied.length; hop++) {
|
|
269
270
|
// Latest applied correction FROM the current label wins: a later human
|
|
270
271
|
// decision supersedes an earlier one.
|
|
271
|
-
const next = [...applied].reverse().find(r => r.from
|
|
272
|
+
const next = [...applied].reverse().find(r => sameSpeakerName(r.from, label))
|
|
272
273
|
if (!next || seen.has(next.to)) break
|
|
273
274
|
label = next.to
|
|
274
275
|
seen.add(label)
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
// every future meeting. A wrong write is permanent and silent. So every step below
|
|
38
38
|
// fails CLOSED: no mapping means no enrolment, never a fall back to positions.
|
|
39
39
|
|
|
40
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
40
41
|
import {
|
|
41
42
|
chunkEmbeddingTtlMs,
|
|
42
43
|
chunkEmbeddingsEnabled,
|
|
@@ -163,7 +164,7 @@ function missingReason(sidecar: Record<string, unknown>): EnrolmentSkipReason {
|
|
|
163
164
|
* append as a creation. */
|
|
164
165
|
function profileExists(name: string): boolean {
|
|
165
166
|
try {
|
|
166
|
-
return readVoiceProfiles().profiles.some(p => p.name
|
|
167
|
+
return readVoiceProfiles().profiles.some(p => sameSpeakerName(p.name, name))
|
|
167
168
|
} catch {
|
|
168
169
|
// Unknowable is not "new". Claiming creation is the lie that matters here.
|
|
169
170
|
return true
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// somebody else. Reported as `proseStale` instead.
|
|
28
28
|
|
|
29
29
|
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
30
|
+
import { nameForms, nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
30
31
|
|
|
31
32
|
/** A label that would corrupt the file formats it gets written into. */
|
|
32
33
|
export function invalidLabelReason(label: string): string | null {
|
|
@@ -75,6 +76,10 @@ export function relabelSidecarJson(
|
|
|
75
76
|
to: string,
|
|
76
77
|
chunks: number[] = [],
|
|
77
78
|
): RelabelOutcome<SidecarRelabelResult> {
|
|
79
|
+
// Names are NFC here whatever arrived (a decomposed spelling must never be written), and matched NFC on both sides, so a chunk
|
|
80
|
+
// already stored decomposed still counts as carrying `from`.
|
|
81
|
+
from = nfc(from)
|
|
82
|
+
to = nfc(to)
|
|
78
83
|
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
79
84
|
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
80
85
|
const bad = invalidLabelReason(label)
|
|
@@ -100,7 +105,7 @@ export function relabelSidecarJson(
|
|
|
100
105
|
if (restrict) {
|
|
101
106
|
const mismatched = chunks.filter(i => {
|
|
102
107
|
const row = rows[i]
|
|
103
|
-
return !row || typeof row !== 'object' || (row as Record<string, unknown>).speaker
|
|
108
|
+
return !row || typeof row !== 'object' || !sameSpeakerName((row as Record<string, unknown>).speaker, from)
|
|
104
109
|
})
|
|
105
110
|
if (mismatched.length > 0) {
|
|
106
111
|
return { ok: false, error: `chunks do not carry "${from}": ${mismatched.slice(0, 8).join(', ')}` }
|
|
@@ -111,11 +116,11 @@ export function relabelSidecarJson(
|
|
|
111
116
|
let remainingWithFrom = 0
|
|
112
117
|
rows.forEach((row, i) => {
|
|
113
118
|
// The `typeof` half guards the cast below rather than any behaviour — a
|
|
114
|
-
// truthy non-object can never satisfy
|
|
119
|
+
// truthy non-object can never satisfy the NFC name match below, so it is
|
|
115
120
|
// deliberately not claimed as tested.
|
|
116
121
|
if (!row || typeof row !== 'object') return
|
|
117
122
|
const r = row as Record<string, unknown>
|
|
118
|
-
if (r.speaker
|
|
123
|
+
if (!sameSpeakerName(r.speaker, from)) return
|
|
119
124
|
if (restrict && !restrict.has(i)) { remainingWithFrom++; return }
|
|
120
125
|
r.speaker = to
|
|
121
126
|
changed.push(i)
|
|
@@ -131,18 +136,18 @@ export function relabelSidecarJson(
|
|
|
131
136
|
if (isVoiceLabel(to)) {
|
|
132
137
|
// 6.90.0: a Mac `Voice N` placeholder never enters `speakers` (the store
|
|
133
138
|
// keeps it out too), so merging into one adds nothing. Retire `from` only.
|
|
134
|
-
const at = speakers.
|
|
139
|
+
const at = speakers.findIndex(x => sameSpeakerName(x, from))
|
|
135
140
|
if (at >= 0 && remainingWithFrom === 0) speakers.splice(at, 1)
|
|
136
|
-
} else if (!speakers.
|
|
141
|
+
} else if (!speakers.some(x => sameSpeakerName(x, to))) {
|
|
137
142
|
// Replace in place when `from` is being fully retired, so the list keeps its
|
|
138
143
|
// original ordering rather than moving the person to the end.
|
|
139
|
-
const at = speakers.
|
|
144
|
+
const at = speakers.findIndex(x => sameSpeakerName(x, from))
|
|
140
145
|
if (at >= 0 && remainingWithFrom === 0) speakers[at] = to
|
|
141
146
|
else speakers.push(to)
|
|
142
147
|
} else if (remainingWithFrom === 0) {
|
|
143
148
|
// `to` already listed and `from` fully retired — merging a split identity.
|
|
144
149
|
// Drop the old entry rather than leaving a name nothing is attributed to.
|
|
145
|
-
const at = speakers.
|
|
150
|
+
const at = speakers.findIndex(x => sameSpeakerName(x, from))
|
|
146
151
|
if (at >= 0) speakers.splice(at, 1)
|
|
147
152
|
}
|
|
148
153
|
doc.speakers = speakers
|
|
@@ -198,6 +203,8 @@ export function relabelMeetingMarkdown(
|
|
|
198
203
|
to: string,
|
|
199
204
|
options: { removeAttendee?: boolean } = {},
|
|
200
205
|
): RelabelOutcome<MarkdownRelabelResult> {
|
|
206
|
+
from = nfc(from)
|
|
207
|
+
to = nfc(to)
|
|
201
208
|
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
202
209
|
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
203
210
|
const bad = invalidLabelReason(label)
|
|
@@ -215,7 +222,7 @@ export function relabelMeetingMarkdown(
|
|
|
215
222
|
// Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
|
|
216
223
|
// run past its own line. Without the end anchor, `- Theo M` matches the
|
|
217
224
|
// PREFIX of `- Theo Marsh` and deleting it leaves the fragment `enry`.
|
|
218
|
-
const line = (label: string) => `^-[ \t]+${
|
|
225
|
+
const line = (label: string) => `^-[ \t]+${nameForms(label, escapeRegExp)}[ \t]*$`
|
|
219
226
|
// A de-attribution must DELETE the attendee bullet, never rename it. Renaming
|
|
220
227
|
// writes `- Unidentified 2` into the attendee list as though it were a
|
|
221
228
|
// person, and the COS pipeline that BUILDS attendees deliberately excludes
|
|
@@ -241,7 +248,7 @@ export function relabelMeetingMarkdown(
|
|
|
241
248
|
if (tr) {
|
|
242
249
|
const body = out.slice(tr.start, tr.end)
|
|
243
250
|
const rewritten = body.replace(
|
|
244
|
-
new RegExp(`^\\[${
|
|
251
|
+
new RegExp(`^\\[${nameForms(from, escapeRegExp)}\\]:`, 'gm'),
|
|
245
252
|
() => { transcript++; return `[${to}]:` },
|
|
246
253
|
)
|
|
247
254
|
out = out.slice(0, tr.start) + rewritten + out.slice(tr.end)
|
|
@@ -276,7 +283,7 @@ export function detectProseReferences(md: string, label: string): string[] {
|
|
|
276
283
|
prose += md.slice(cursor)
|
|
277
284
|
|
|
278
285
|
const hits: string[] = []
|
|
279
|
-
const full = new RegExp(`\\b${
|
|
286
|
+
const full = new RegExp(`\\b${nameForms(label, escapeRegExp)}\\b`)
|
|
280
287
|
if (full.test(prose)) hits.push(label)
|
|
281
288
|
const first = label.split(/\s+/)[0]
|
|
282
289
|
// Only a multi-word label has a distinct bare-first-name form. 'MU' or 'Ext'
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import type { QualifiedMachinePosition } from './mac-speaker-machine-evidence.js'
|
|
26
26
|
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
27
|
+
import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
27
28
|
|
|
28
29
|
/** One transcript chunk as stored in the `.g2-chunks.json` sidecar. */
|
|
29
30
|
export interface ReviewChunk {
|
|
@@ -605,10 +606,10 @@ export function reviewMeetingSpeakers(
|
|
|
605
606
|
} = {},
|
|
606
607
|
): MeetingSpeakerReview {
|
|
607
608
|
const owner = options.owner ?? 'Me'
|
|
608
|
-
const confirmed = new Set(options.confirmed ?? [])
|
|
609
|
+
const confirmed = new Set([...(options.confirmed ?? [])].map(nfc))
|
|
609
610
|
for (const label of new Set(chunks.map(c => c.speaker ?? ''))) {
|
|
610
611
|
const positions = chunks.flatMap((c, i) => c.speaker === label ? [i] : [])
|
|
611
|
-
if (positions.length > 0 && positions.every(i => options.confirmedChunks?.get(i)
|
|
612
|
+
if (positions.length > 0 && positions.every(i => sameSpeakerName(options.confirmedChunks?.get(i), label))) confirmed.add(nfc(label))
|
|
612
613
|
}
|
|
613
614
|
const limit = options.phrasesPerVoice ?? 3
|
|
614
615
|
const sequence = chunks.map(c => c.speaker ?? '')
|
|
@@ -668,14 +669,14 @@ export function reviewMeetingSpeakers(
|
|
|
668
669
|
if (c.speaker !== label) return n
|
|
669
670
|
const proof = options.machineQualifiedPositions?.get(i)
|
|
670
671
|
const machine = proof?.label === label && (c.chunkIndex === undefined || c.chunkIndex === proof.rawChunkIndex) && proof.independentWindows >= 2 && proof.minimumSimilarity >= .88 && proof.minimumMargin >= .05
|
|
671
|
-
const human = confirmed.has(label) || options.confirmedChunks?.get(i)
|
|
672
|
+
const human = confirmed.has(nfc(label)) || sameSpeakerName(options.confirmedChunks?.get(i), label)
|
|
672
673
|
const capture = typeof c.similarity === 'number' && Number.isFinite(c.similarity) && c.similarity >= ASSERT_MIN_SIMILARITY
|
|
673
674
|
return n + Number(!machine && !human && !capture)
|
|
674
675
|
}, 0) : 0
|
|
675
676
|
const runs = speakerRuns(sequence, label)
|
|
676
677
|
const unattributed = isUnattributed(label)
|
|
677
|
-
const confirmedSegments = unattributed ? 0 : confirmed.has(label) ? own.length
|
|
678
|
-
: chunks.reduce((n, c, i) => n + Number(c.speaker === label && options.confirmedChunks?.get(i)
|
|
678
|
+
const confirmedSegments = unattributed ? 0 : confirmed.has(nfc(label)) ? own.length
|
|
679
|
+
: chunks.reduce((n, c, i) => n + Number(c.speaker === label && sameSpeakerName(options.confirmedChunks?.get(i), label)), 0)
|
|
679
680
|
|
|
680
681
|
const thrashesWith: ThrashPair[] = []
|
|
681
682
|
if (!unattributed) {
|
|
@@ -708,7 +709,7 @@ export function reviewMeetingSpeakers(
|
|
|
708
709
|
const assertionBlockers: string[] = []
|
|
709
710
|
if (unattributed) {
|
|
710
711
|
assertionBlockers.push('no name was ever assigned to this voice')
|
|
711
|
-
} else if (confirmed.has(label)) {
|
|
712
|
+
} else if (confirmed.has(nfc(label))) {
|
|
712
713
|
// A human said this is them. Blockers stay OFF the list rather than being
|
|
713
714
|
// listed-then-overridden, because a reviewer reading "similarity 0.56
|
|
714
715
|
// below 0.65" under a name they personally confirmed would reasonably
|
|
@@ -753,10 +754,10 @@ export function reviewMeetingSpeakers(
|
|
|
753
754
|
longestRun: runs.length ? Math.max(...runs) : 0,
|
|
754
755
|
isOwner: label === owner,
|
|
755
756
|
/** True when a human vouched for this label in this meeting. */
|
|
756
|
-
confirmedByHuman: confirmed.has(label),
|
|
757
|
+
confirmedByHuman: confirmed.has(nfc(label)),
|
|
757
758
|
machineQualified: !unattributed && machineQualified,
|
|
758
759
|
machineQualifiedSegments: unattributed ? 0 : machineQualifiedSegments,
|
|
759
|
-
assertionSource: assertionBlockers.length ? null : confirmed.has(label) ? 'human' : label === owner ? 'owner' : machineQualified ? 'qualified-profile' : machineQualifiedSegments > 0 ? 'capture-and-qualified-profile' : 'capture-match',
|
|
760
|
+
assertionSource: assertionBlockers.length ? null : confirmed.has(nfc(label)) ? 'human' : label === owner ? 'owner' : machineQualified ? 'qualified-profile' : machineQualifiedSegments > 0 ? 'capture-and-qualified-profile' : 'capture-match',
|
|
760
761
|
reliability,
|
|
761
762
|
nameAsserted: assertionBlockers.length === 0,
|
|
762
763
|
assertionBlockers,
|