@gotcos/glasses-server 6.36.4 → 6.36.6
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 +51 -0
- package/package.json +1 -1
- package/server/lib/agent-session-store.ts +69 -4
- package/server/lib/speaker-rename-fanout.ts +311 -0
- package/server/routes/voice.ts +82 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.36.6
|
|
4
|
+
- **The session digest follows the thread instead of its opening.** Miles, from the
|
|
5
|
+
lens: "It's currently showing a legacy session that I had over a day ago... The
|
|
6
|
+
discussion should show the questions that we're asking and a summary of those most
|
|
7
|
+
recent things, not something that's the 'first' message." The whole budget now goes
|
|
8
|
+
to recency. `DIGEST_HEAD_TURNS` drops from 2 to 0 — a deliberate reversal of the
|
|
9
|
+
earlier rule that reserved the opening ask because it "frames everything after it".
|
|
10
|
+
The opening is not lost: `first_prompt` still carries it in full.
|
|
11
|
+
- **Harness-injected rows no longer render as things you asked.** A slash-command body
|
|
12
|
+
and a compaction preamble are both written as USER rows, so both appeared in the
|
|
13
|
+
DISCUSSION list. Filtered on the STRUCTURAL flags Claude already sets — `isMeta` and
|
|
14
|
+
`isCompactSummary` — not on a markdown heuristic that could misfire on a real paste.
|
|
15
|
+
The compaction preamble is also filtered by text as a fallback for providers that
|
|
16
|
+
emit no flag, and that filter reaches titles and the search index too: the preamble
|
|
17
|
+
is byte-identical across every compacted session, so as a title or a search hit it
|
|
18
|
+
distinguishes nothing.
|
|
19
|
+
- **On a truncated read the head window no longer feeds the recency list.** The head
|
|
20
|
+
window IS the session opening, and on a large session the 60-turn window never fills,
|
|
21
|
+
so those turns survived at the top of the digest indefinitely. Measured on a real
|
|
22
|
+
94 MiB session: both leading bullets were head-window turns from the previous day.
|
|
23
|
+
A whole-file read is untouched — it yields `tail: true` for every line.
|
|
24
|
+
- Verified by parsing real transcripts, not fixtures: the 94 MiB session now leads with
|
|
25
|
+
the two most recent asks, and an ordinary 2.7 MiB session renders nine recent turns
|
|
26
|
+
in reading order.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## 6.36.5
|
|
30
|
+
- **A speaker merge now reaches the meetings, not just the voice store.**
|
|
31
|
+
`merge-profiles` folded two profiles together and relabelled the calibration log,
|
|
32
|
+
and stopped there. Meetings keep the speaker strings written at transcription time
|
|
33
|
+
and the review panel re-reads them from disk, so every meeting recorded before a
|
|
34
|
+
merge kept rendering two people forever — the merge fixed identification going
|
|
35
|
+
forward and nothing behind it. Measured on the live library for one real merge:
|
|
36
|
+
24 sidecars (111 labels) and 18 transcripts (70 labels) were stranded.
|
|
37
|
+
- The fan-out is a **pure string rewrite** — the same `relabelSidecarJson` and
|
|
38
|
+
`relabelMeetingMarkdown` primitives the per-meeting relabel route uses, minus its
|
|
39
|
+
`enrolNamedVoice` step. Re-enrolling would double-count audio the merge has already
|
|
40
|
+
absorbed and drag the centroid, which matters at the margin: one recent merge moved
|
|
41
|
+
a neighbouring speaker from 0.818 to 0.842.
|
|
42
|
+
- **The confirm gate now shows the blast radius before you approve it**, scoped to the
|
|
43
|
+
names that would actually merge. Dry run is the default, writes are atomic, and the
|
|
44
|
+
response names every file touched so the rewrite can be audited and diffed.
|
|
45
|
+
- Renames only what the store actually absorbed. A requested name that matched no
|
|
46
|
+
profile is reported as missing and left alone everywhere — fanning out the requested
|
|
47
|
+
list instead would have renamed a real person off the back of a typo.
|
|
48
|
+
- Reports what it cannot fix rather than passing over it: transcripts in the older
|
|
49
|
+
`**Name**` label form are counted and surfaced, not silently skipped. On the live
|
|
50
|
+
library that is 12 files carrying 58 labels a rename leaves behind.
|
|
51
|
+
- iCloud conflict copies are skipped by construction, including the compound-extension
|
|
52
|
+
form (`sync 2.g2-chunks.json`) an earlier pass let through.
|
|
53
|
+
|
|
3
54
|
## 6.28.0
|
|
4
55
|
- **Continue Original Agent Thread — attach to a live desktop thread and append a turn
|
|
5
56
|
to it.** From the glasses you can now continue a Claude Code or Codex conversation that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.36.
|
|
3
|
+
"version": "6.36.6",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -103,6 +103,22 @@ export function isKeepWarmSessionTitle(title: string): boolean {
|
|
|
103
103
|
return t.startsWith('this is an automated local readiness check')
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The compaction preamble, which the harness writes as a USER turn.
|
|
108
|
+
*
|
|
109
|
+
* Miles's 2026-08-17 screenshot had this sitting in the DISCUSSION list as though he
|
|
110
|
+
* had asked it: "This session is being continued from a previous conversation… Summary:
|
|
111
|
+
* ## 1. Primary Request and Intent". It is scaffolding, and it lands in the digest at
|
|
112
|
+
* full length because it IS recent -- compaction happens mid-session -- so recency
|
|
113
|
+
* ordering alone does not remove it.
|
|
114
|
+
*
|
|
115
|
+
* Measured across the 25 most recently written transcripts on this machine: 29
|
|
116
|
+
* occurrences, exactly ONE distinct opening. Anchored to that opening rather than to a
|
|
117
|
+
* loose "continued from" match, so a human sentence about continuing a conversation is
|
|
118
|
+
* not mistaken for it.
|
|
119
|
+
*/
|
|
120
|
+
const COMPACTION_PREAMBLE = /^this session is being continued from a previous conversation/i
|
|
121
|
+
|
|
106
122
|
export function isWrapperPrompt(text: string): boolean {
|
|
107
123
|
const trimmed = text.trim()
|
|
108
124
|
return trimmed.startsWith('<')
|
|
@@ -110,6 +126,10 @@ export function isWrapperPrompt(text: string): boolean {
|
|
|
110
126
|
|| trimmed.startsWith('You are an agent')
|
|
111
127
|
|| trimmed.startsWith('You are QA Agent')
|
|
112
128
|
|| trimmed.startsWith('Message Type:')
|
|
129
|
+
// Filtered HERE rather than only in the digest, so it also stops titling a session
|
|
130
|
+
// and stops being indexed for search. It is byte-identical across every compacted
|
|
131
|
+
// session, so as a title or a search hit it distinguishes nothing.
|
|
132
|
+
|| COMPACTION_PREAMBLE.test(trimmed)
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
export function firstLineTitle(text: string): string {
|
|
@@ -249,7 +269,27 @@ export const DISCUSSION_DIGEST_MAX = 2000
|
|
|
249
269
|
const DIGEST_TURN_MAX = 220
|
|
250
270
|
|
|
251
271
|
/** Turns kept from the START. The opening ask frames everything after it. */
|
|
252
|
-
|
|
272
|
+
/**
|
|
273
|
+
* User turns reserved from the START of the session before recency gets the budget.
|
|
274
|
+
*
|
|
275
|
+
* ZERO, AND THAT IS A REVERSAL. This was 2, on the reasoning that the opening ask
|
|
276
|
+
* "frames everything after it" -- see the head-first block in composeDiscussionDigest,
|
|
277
|
+
* which was itself a fix for a digest that spent everything on the tail.
|
|
278
|
+
*
|
|
279
|
+
* Miles overruled it from hardware, 2026-08-17, looking at a digest whose first two
|
|
280
|
+
* bullets were a question from the previous day and a compaction preamble: "It needs to
|
|
281
|
+
* refresh to the most recent response inside of the thread. The discussion should show
|
|
282
|
+
* the questions that we're asking and a summary of those most recent things, not
|
|
283
|
+
* something that's the 'first' message."
|
|
284
|
+
*
|
|
285
|
+
* The opening ask is NOT lost -- it is still carried, in full, in `first_prompt`, which
|
|
286
|
+
* the digest and the row label both fall back to. What changes is that it stops
|
|
287
|
+
* occupying the two most valuable slots in a list about what is happening NOW.
|
|
288
|
+
*
|
|
289
|
+
* Left as a constant rather than deleting the head path: this is a judgement call about
|
|
290
|
+
* emphasis, and one number is the whole reversal if Miles wants some framing back.
|
|
291
|
+
*/
|
|
292
|
+
const DIGEST_HEAD_TURNS = 0
|
|
253
293
|
|
|
254
294
|
/** Recent turns retained while streaming. Comfortably more than 2000 chars can
|
|
255
295
|
* render (~20-25 at typical length), so the budget and not the buffer decides
|
|
@@ -1341,7 +1381,32 @@ export async function parseAgentSession(
|
|
|
1341
1381
|
// reports what was actually dropped rather than what this buffer happens to hold.
|
|
1342
1382
|
const digestHead: string[] = []
|
|
1343
1383
|
const digestRecent: string[] = []
|
|
1344
|
-
|
|
1384
|
+
/**
|
|
1385
|
+
* Add a user turn to the recency digest -- or decline to.
|
|
1386
|
+
*
|
|
1387
|
+
* TWO DECLINES, both found by parsing Miles's own 94 MiB session rather than by
|
|
1388
|
+
* reasoning about the code.
|
|
1389
|
+
*
|
|
1390
|
+
* `injected`: STRUCTURAL, not a heuristic. Claude marks a slash-command body with
|
|
1391
|
+
* `isMeta: true` and a compaction preamble with `isCompactSummary: true`. Both are
|
|
1392
|
+
* written as USER rows, so both rendered in the digest as things Miles had asked --
|
|
1393
|
+
* his screenshot led with "This session is being continued from a previous
|
|
1394
|
+
* conversation… Summary: ## 1. Primary Request and Intent". Neither is an ask, and
|
|
1395
|
+
* both are recent, so recency ordering alone leaves them exactly where they were.
|
|
1396
|
+
*
|
|
1397
|
+
* `fromTail` on a TRUNCATED read: the head window IS the session opening. Feeding it
|
|
1398
|
+
* into a recency list is the very thing Miles objected to -- "not something that's
|
|
1399
|
+
* the 'first' message" -- and on a large session the 60-turn window never fills, so
|
|
1400
|
+
* those opening turns survive to the top of the digest forever. Measured on his
|
|
1401
|
+
* session: the two leading bullets were both head-window turns from the previous day.
|
|
1402
|
+
* The head is still read and still published, as `first_prompt`.
|
|
1403
|
+
*
|
|
1404
|
+
* A whole-file read yields `tail: true` for every line, so a normal session is
|
|
1405
|
+
* unaffected -- this narrows only the partial read.
|
|
1406
|
+
*/
|
|
1407
|
+
const collectTurn = (text: string, fromTail = true, injected = false): void => {
|
|
1408
|
+
if (injected) return
|
|
1409
|
+
if (truncated && !fromTail) return
|
|
1345
1410
|
// Reuse the wrapper filter the rest of this module already trusts. Without it the
|
|
1346
1411
|
// opening turns of a slash-command session render as
|
|
1347
1412
|
// "<command-message>cos-glasses</command-message>…" — scaffolding, not the ask,
|
|
@@ -1413,7 +1478,7 @@ export async function parseAgentSession(
|
|
|
1413
1478
|
}
|
|
1414
1479
|
if (obj.type === 'user') {
|
|
1415
1480
|
userCount += 1
|
|
1416
|
-
collectTurn(text)
|
|
1481
|
+
collectTurn(text, tail, obj.isMeta === true || obj.isCompactSummary === true)
|
|
1417
1482
|
if (!firstPrompt) firstPrompt = firstLineTitle(text)
|
|
1418
1483
|
if (!title) title = firstLineTitle(text)
|
|
1419
1484
|
} else {
|
|
@@ -1459,7 +1524,7 @@ export async function parseAgentSession(
|
|
|
1459
1524
|
if (obj.role === 'user') {
|
|
1460
1525
|
userCount += 1
|
|
1461
1526
|
const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
|
|
1462
|
-
collectTurn(query ?? text)
|
|
1527
|
+
collectTurn(query ?? text, tail)
|
|
1463
1528
|
if (query) {
|
|
1464
1529
|
title = query
|
|
1465
1530
|
if (!firstPrompt) firstPrompt = query
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Carry a speaker rename out to the meetings that already have the old name baked in.
|
|
2
|
+
//
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// `merge-profiles` folds two voice profiles together and relabels the calibration log.
|
|
7
|
+
// That is ALL it has ever touched -- verified in the handler, not assumed. Meetings keep
|
|
8
|
+
// the speaker strings that were written at transcription time, and the review panel
|
|
9
|
+
// re-reads those strings from disk on every request, so a merge is invisible to every
|
|
10
|
+
// meeting that already exists.
|
|
11
|
+
//
|
|
12
|
+
// Measured on this machine when the Luke H / Luke Henry merge ran: the split had been
|
|
13
|
+
// live since 2026-03-25, and every affected meeting would have rendered two Lukes
|
|
14
|
+
// forever. The merge fixed identification going FORWARD and nothing behind it.
|
|
15
|
+
//
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// WHY IT DOES NOT REUSE THE PER-MEETING RELABEL ROUTE
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// `POST /api/meeting/:id/relabel` also calls `enrolNamedVoice`, which folds that
|
|
20
|
+
// meeting's audio into the target profile. That is correct when a human names a voice:
|
|
21
|
+
// the embedding genuinely should learn it.
|
|
22
|
+
//
|
|
23
|
+
// It is WRONG for a merge fan-out, and not by a little. The merge has already absorbed
|
|
24
|
+
// those embeddings; re-enrolling the same audio across every affected meeting would
|
|
25
|
+
// double-count it and drag the centroid further. That matters here specifically because
|
|
26
|
+
// the Luke merge already moved a neighbouring speaker's similarity from 0.818 to 0.842,
|
|
27
|
+
// and 0.842 is close enough to the identification threshold to start producing wrong
|
|
28
|
+
// attributions.
|
|
29
|
+
//
|
|
30
|
+
// So this is a PURE STRING REWRITE. Same two primitives the relabel route uses --
|
|
31
|
+
// `relabelSidecarJson` and `relabelMeetingMarkdown` -- minus the enrolment.
|
|
32
|
+
//
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// SAFETY
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// This rewrites production meeting records, so:
|
|
37
|
+
// - DRY RUN IS THE DEFAULT. Writing requires asking for it.
|
|
38
|
+
// - Atomic writes only, via the same helper the rest of the server uses.
|
|
39
|
+
// - iCloud conflict copies are skipped by construction. Desktop-and-Documents sync
|
|
40
|
+
// creates `2026-08 2/` and `meeting 2.md`; rewriting one of those would edit a file
|
|
41
|
+
// nothing reads while leaving the real one stale. Month directories must match
|
|
42
|
+
// `YYYY-MM` exactly.
|
|
43
|
+
// - Every skip is REPORTED rather than silently dropped, because a fan-out that
|
|
44
|
+
// quietly missed files is worse than one that refused.
|
|
45
|
+
|
|
46
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
47
|
+
import { join } from 'node:path'
|
|
48
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
49
|
+
import {
|
|
50
|
+
relabelSidecarJson,
|
|
51
|
+
relabelMeetingMarkdown,
|
|
52
|
+
type SidecarRelabelResult,
|
|
53
|
+
type MarkdownRelabelResult,
|
|
54
|
+
} from './meeting-relabel.js'
|
|
55
|
+
|
|
56
|
+
/** A month directory, and nothing that merely looks like one. */
|
|
57
|
+
const CANONICAL_MONTH = /^\d{4}-\d{2}$/
|
|
58
|
+
|
|
59
|
+
/** The chunk sidecar that drives the speaker review panel. */
|
|
60
|
+
const SIDECAR_SUFFIX = '.g2-chunks.json'
|
|
61
|
+
|
|
62
|
+
export interface FanOutFile {
|
|
63
|
+
path: string
|
|
64
|
+
/** Labels rewritten in this file. */
|
|
65
|
+
labels: number
|
|
66
|
+
/**
|
|
67
|
+
* Something true about this file the operator should know: chunks that still carry
|
|
68
|
+
* the old name after a partial relabel, or narrative prose the primitive leaves alone
|
|
69
|
+
* on purpose. Absent when the rewrite was total.
|
|
70
|
+
*/
|
|
71
|
+
note?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface FanOutSkip {
|
|
75
|
+
path: string
|
|
76
|
+
reason: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SpeakerRenameFanOut {
|
|
80
|
+
from: string
|
|
81
|
+
to: string
|
|
82
|
+
dryRun: boolean
|
|
83
|
+
sidecars: FanOutFile[]
|
|
84
|
+
markdown: FanOutFile[]
|
|
85
|
+
/** Files opened and examined, whether or not they matched. */
|
|
86
|
+
scanned: number
|
|
87
|
+
/** Anything deliberately not touched, and why. */
|
|
88
|
+
skipped: FanOutSkip[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface FanOutOptions {
|
|
92
|
+
/** Write. Omitted or false means report what WOULD change and touch nothing. */
|
|
93
|
+
apply?: boolean
|
|
94
|
+
/**
|
|
95
|
+
* Include the hidden `.meeting_archive` tree.
|
|
96
|
+
*
|
|
97
|
+
* Off by default: it is a separate store with its own lifecycle, and sweeping it in
|
|
98
|
+
* silently would triple the blast radius of a rename without anyone asking for it.
|
|
99
|
+
*/
|
|
100
|
+
includeArchive?: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function safeReaddir(dir: string): string[] {
|
|
104
|
+
try {
|
|
105
|
+
return readdirSync(dir)
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isDir(path: string): boolean {
|
|
112
|
+
try {
|
|
113
|
+
return statSync(path).isDirectory()
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every `{domain}/meetings/{YYYY-MM}` directory under an operations root.
|
|
121
|
+
*
|
|
122
|
+
* Walked explicitly rather than recursively: the operations tree also holds weekly work
|
|
123
|
+
* folders, intelligence libraries and caches, and a rename has no business in any of
|
|
124
|
+
* them. Three known levels, and nothing else is opened.
|
|
125
|
+
*/
|
|
126
|
+
export function meetingMonthDirs(operationsDir: string, includeArchive = false): string[] {
|
|
127
|
+
const out: string[] = []
|
|
128
|
+
for (const domain of safeReaddir(operationsDir)) {
|
|
129
|
+
if (domain.startsWith('.') && !(includeArchive && domain === '.meeting_archive')) continue
|
|
130
|
+
const meetingsDir = join(operationsDir, domain, 'meetings')
|
|
131
|
+
if (!isDir(meetingsDir)) {
|
|
132
|
+
// `.meeting_archive` nests one level deeper: `.meeting_archive/{name}/meetings`.
|
|
133
|
+
if (includeArchive && domain === '.meeting_archive') {
|
|
134
|
+
for (const sub of safeReaddir(join(operationsDir, domain))) {
|
|
135
|
+
const nested = join(operationsDir, domain, sub, 'meetings')
|
|
136
|
+
if (isDir(nested)) out.push(...monthsIn(nested))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
out.push(...monthsIn(meetingsDir))
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function monthsIn(meetingsDir: string): string[] {
|
|
147
|
+
const out: string[] = []
|
|
148
|
+
for (const month of safeReaddir(meetingsDir)) {
|
|
149
|
+
// STRICT. `2026-08 2` is an iCloud conflict copy; rewriting it would edit a file
|
|
150
|
+
// nothing reads and leave the real one carrying the old name.
|
|
151
|
+
if (!CANONICAL_MONTH.test(month)) continue
|
|
152
|
+
const dir = join(meetingsDir, month)
|
|
153
|
+
if (isDir(dir)) out.push(dir)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Rewrite `from` to `to` across every meeting sidecar and transcript.
|
|
160
|
+
*
|
|
161
|
+
* Returns what changed, or what WOULD change when `apply` is not set. Never throws for
|
|
162
|
+
* one bad file: an unreadable or unparsable record is reported as a skip and the sweep
|
|
163
|
+
* continues, because stopping halfway would leave the library in a half-renamed state
|
|
164
|
+
* that is worse than either end.
|
|
165
|
+
*/
|
|
166
|
+
export function fanOutSpeakerRename(
|
|
167
|
+
operationsDir: string,
|
|
168
|
+
from: string,
|
|
169
|
+
to: string,
|
|
170
|
+
options: FanOutOptions = {},
|
|
171
|
+
): SpeakerRenameFanOut {
|
|
172
|
+
const apply = options.apply === true
|
|
173
|
+
const result: SpeakerRenameFanOut = {
|
|
174
|
+
from, to, dryRun: !apply, sidecars: [], markdown: [], scanned: 0, skipped: [],
|
|
175
|
+
}
|
|
176
|
+
if (!from.trim() || !to.trim() || from === to) return result
|
|
177
|
+
|
|
178
|
+
for (const monthDir of meetingMonthDirs(operationsDir, options.includeArchive === true)) {
|
|
179
|
+
for (const name of safeReaddir(monthDir)) {
|
|
180
|
+
// A conflict copy of a FILE, same reasoning as the month directory.
|
|
181
|
+
//
|
|
182
|
+
// The marker sits before the EXTENSION CHAIN, not just before the last dot.
|
|
183
|
+
// iCloud writes `sync.g2-chunks 2.json` for a sidecar and `sync 2.md` for a
|
|
184
|
+
// transcript, and a sidecar belonging to an already-conflicted meeting comes
|
|
185
|
+
// out as `sync 2.g2-chunks.json`. A first cut anchored the digit to the final
|
|
186
|
+
// extension and let that third form straight through -- it would have rewritten
|
|
187
|
+
// a duplicate nobody reads while the real file kept the old name.
|
|
188
|
+
if (/ \d+(\.[A-Za-z0-9-]+)*\.(md|json)$/.test(name)) {
|
|
189
|
+
result.skipped.push({ path: join(monthDir, name), reason: 'icloud conflict copy' })
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const isSidecar = name.endsWith(SIDECAR_SUFFIX)
|
|
193
|
+
const isMarkdown = name.endsWith('.md')
|
|
194
|
+
if (!isSidecar && !isMarkdown) continue
|
|
195
|
+
|
|
196
|
+
const path = join(monthDir, name)
|
|
197
|
+
result.scanned += 1
|
|
198
|
+
let raw: string
|
|
199
|
+
try {
|
|
200
|
+
raw = readFileSync(path, 'utf-8')
|
|
201
|
+
} catch (error) {
|
|
202
|
+
result.skipped.push({ path, reason: `unreadable: ${(error as Error).message}` })
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
// Cheap pre-filter, and deliberately loose: `from` is a SUBSTRING of the name it
|
|
206
|
+
// is being merged into ("Luke H" inside "Luke Henry"), so a file holding only the
|
|
207
|
+
// new name still passes here. The primitives below are exact and reject it, which
|
|
208
|
+
// is why a plain no-match must not be reported as a skip -- see below.
|
|
209
|
+
if (!raw.includes(from)) continue
|
|
210
|
+
|
|
211
|
+
// TYPED, NOT DUCK-TYPED. A first cut read `changed` as a number and the field is
|
|
212
|
+
// an ARRAY of chunk indices, so every sidecar would have counted zero labels and
|
|
213
|
+
// been skipped -- a fan-out that reported success while rewriting nothing.
|
|
214
|
+
let next: string
|
|
215
|
+
let labels: number
|
|
216
|
+
let note: string | undefined
|
|
217
|
+
|
|
218
|
+
if (isSidecar) {
|
|
219
|
+
const outcome = relabelSidecarJson(raw, from, to)
|
|
220
|
+
if (!outcome.ok) {
|
|
221
|
+
if (!isNoMatch(outcome.error)) result.skipped.push({ path, reason: outcome.error })
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
const value: SidecarRelabelResult = outcome.value
|
|
225
|
+
next = value.json
|
|
226
|
+
labels = value.changed.length
|
|
227
|
+
// The primitive's own invariant, carried rather than assumed: a partial relabel
|
|
228
|
+
// means chunks still hold the old name, and the markdown must NOT then be
|
|
229
|
+
// rewritten by label. Reported so the operator sees it.
|
|
230
|
+
if (value.remainingWithFrom > 0) {
|
|
231
|
+
note = `${value.remainingWithFrom} chunk(s) still carry "${from}"`
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
// A SECOND TRANSCRIPT FORMAT EXISTS AND THE PRIMITIVE DOES NOT HANDLE IT.
|
|
235
|
+
//
|
|
236
|
+
// `relabelMeetingMarkdown` rewrites `[Name]:` turn labels, anchored to line
|
|
237
|
+
// start. Measured across the real library for the Luke rename: 17 files and 97
|
|
238
|
+
// labels in that form -- and 20 files, 77 labels in a `**Name**` form it does
|
|
239
|
+
// not match, 13 of those files in LIVE quilt meetings, not the archive.
|
|
240
|
+
//
|
|
241
|
+
// That is a 39% silent miss. Detected and REPORTED rather than fixed here: the
|
|
242
|
+
// primitive is shared with the live per-meeting relabel route, and widening its
|
|
243
|
+
// matcher changes behaviour for a path nobody asked me to touch. The operator
|
|
244
|
+
// gets a number instead of a surprise.
|
|
245
|
+
const unhandled = countBoldLabels(raw, from)
|
|
246
|
+
const outcome = relabelMeetingMarkdown(raw, from, to)
|
|
247
|
+
if (!outcome.ok) {
|
|
248
|
+
if (!isNoMatch(outcome.error)) result.skipped.push({ path, reason: outcome.error })
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
const value: MarkdownRelabelResult = outcome.value
|
|
252
|
+
next = value.markdown
|
|
253
|
+
labels = value.attendees + value.transcript
|
|
254
|
+
// Narrative prose is deliberately untouched by the primitive. Surfaced here so a
|
|
255
|
+
// stale summary is something the operator knows about rather than discovers.
|
|
256
|
+
const notes: string[] = []
|
|
257
|
+
if (unhandled > 0) notes.push(`${unhandled} label(s) in an unhandled **${from}** format`)
|
|
258
|
+
if (value.proseStale) {
|
|
259
|
+
notes.push(`prose still mentions "${from}"${value.proseHits.length ? `: ${value.proseHits.slice(0, 3).join(', ')}` : ''}`)
|
|
260
|
+
}
|
|
261
|
+
if (notes.length > 0) note = notes.join('; ')
|
|
262
|
+
|
|
263
|
+
// A file whose ONLY labels are in the unhandled form changes nothing, and would
|
|
264
|
+
// otherwise fall out of the report entirely -- the exact silent miss this guard
|
|
265
|
+
// exists to prevent. Recorded as a skip so it is visible.
|
|
266
|
+
if (labels === 0 && unhandled > 0) {
|
|
267
|
+
result.skipped.push({ path, reason: `${unhandled} label(s) in an unhandled **${from}** format` })
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (labels === 0) continue
|
|
273
|
+
if (apply) {
|
|
274
|
+
try {
|
|
275
|
+
atomicWriteFileSync(path, next)
|
|
276
|
+
} catch (error) {
|
|
277
|
+
result.skipped.push({ path, reason: `write failed: ${(error as Error).message}` })
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
;(isSidecar ? result.sidecars : result.markdown).push({ path, labels, note })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return result
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Is this refusal simply "the name is not in here", rather than a problem?
|
|
289
|
+
*
|
|
290
|
+
* SKIPS ARE FOR THINGS A HUMAN SHOULD LOOK AT. The loose pre-filter above lets through
|
|
291
|
+
* every file holding the NEW name, because the old one is a substring of it -- measured
|
|
292
|
+
* on the real library, that padded the skip list with 15 files that were never affected.
|
|
293
|
+
* A report where most entries are non-issues is one nobody reads, and it hides the two
|
|
294
|
+
* that matter.
|
|
295
|
+
*/
|
|
296
|
+
function isNoMatch(error: string): boolean {
|
|
297
|
+
return /no chunk carries|not found|does not appear|no .* labelled/i.test(error)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Speaker labels in the `**Name**` transcript form, which the primitive does not rewrite.
|
|
302
|
+
*
|
|
303
|
+
* Counted so the report can say how much a rename LEAVES BEHIND. Anchored to line start
|
|
304
|
+
* for the same reason the primitive anchors its own matcher: a bolded name inside spoken
|
|
305
|
+
* text is a quote, not a label.
|
|
306
|
+
*/
|
|
307
|
+
export function countBoldLabels(markdown: string, name: string): number {
|
|
308
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
309
|
+
return (markdown.match(new RegExp(`^\\*\\*${escaped}\\*\\*`, 'gm')) ?? []).length
|
|
310
|
+
}
|
|
311
|
+
|
package/server/routes/voice.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { trainingSourceFor } from '../lib/training-audio-provenance.js'
|
|
|
14
14
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
15
15
|
import { getVoiceDirectorySnapshot, invalidateVoiceDirectory } from '../lib/voice-directory.js'
|
|
16
16
|
import { greedyDiversitySelect } from '../lib/voice-enrolment-selection.js'
|
|
17
|
+
import { fanOutSpeakerRename, type SpeakerRenameFanOut } from '../lib/speaker-rename-fanout.js'
|
|
18
|
+
import { resolveCosOperationsDir } from '../lib/cos-operations-meetings.js'
|
|
17
19
|
|
|
18
20
|
// These MUST match the writer in transcribe-stream.ts, which saves under
|
|
19
21
|
// dataPath(). They previously resolved relative to __dirname — i.e. inside the
|
|
@@ -572,6 +574,57 @@ voiceRouter.get('/voice/directory', async (req, res) => {
|
|
|
572
574
|
// 20 samples independently — 40 samples of one person, split, each half a
|
|
573
575
|
// weaker representation of them than the union would be.
|
|
574
576
|
//
|
|
577
|
+
/**
|
|
578
|
+
* Carry a merge out to the meetings that already carry the absorbed name.
|
|
579
|
+
*
|
|
580
|
+
* WHY THE ROUTE DOES THIS AT ALL. A merge folds two voice profiles together and
|
|
581
|
+
* relabels the calibration log, and that is where it stopped. Meetings keep the
|
|
582
|
+
* strings written at transcription time and the review panel re-reads them from disk,
|
|
583
|
+
* so every meeting recorded before the merge kept rendering two people forever. The
|
|
584
|
+
* merge fixed identification going FORWARD and nothing behind it.
|
|
585
|
+
*
|
|
586
|
+
* PURE STRING REWRITE, no enrolment. The per-meeting relabel route also calls
|
|
587
|
+
* `enrolNamedVoice`, which is right when a human names a voice and wrong here: the
|
|
588
|
+
* merge has ALREADY absorbed those embeddings, so re-enrolling the same audio across
|
|
589
|
+
* every affected meeting would double-count it and drag the centroid. That matters
|
|
590
|
+
* concretely -- the Luke merge moved a neighbouring speaker from 0.818 to 0.842, close
|
|
591
|
+
* enough to the identification threshold to start producing wrong attributions.
|
|
592
|
+
*
|
|
593
|
+
* NEVER THROWS. A merge that succeeded in the store must not report failure because a
|
|
594
|
+
* meeting file was unreadable; the failure is reported in the payload instead.
|
|
595
|
+
*/
|
|
596
|
+
function fanOutMergeToMeetings(
|
|
597
|
+
merged: readonly string[],
|
|
598
|
+
into: string,
|
|
599
|
+
apply: boolean,
|
|
600
|
+
): { operationsDir: string | null; runs: SpeakerRenameFanOut[]; error?: string } {
|
|
601
|
+
const operationsDir = resolveCosOperationsDir()
|
|
602
|
+
// Not an error. A server with no COS library is a supported install; it simply has
|
|
603
|
+
// no meetings to carry the rename to.
|
|
604
|
+
if (!operationsDir || merged.length === 0) return { operationsDir, runs: [] }
|
|
605
|
+
try {
|
|
606
|
+
return { operationsDir, runs: merged.map(name => fanOutSpeakerRename(operationsDir, name, into, { apply })) }
|
|
607
|
+
} catch (err: unknown) {
|
|
608
|
+
return { operationsDir, runs: [], error: errMsg(err) }
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The one number a human needs before approving: how many files this touches. */
|
|
613
|
+
function fanOutTotals(runs: readonly SpeakerRenameFanOut[]): {
|
|
614
|
+
files: number; labels: number; unhandled: number
|
|
615
|
+
} {
|
|
616
|
+
let files = 0, labels = 0, unhandled = 0
|
|
617
|
+
for (const run of runs) {
|
|
618
|
+
for (const f of [...run.sidecars, ...run.markdown]) {
|
|
619
|
+
files += 1
|
|
620
|
+
labels += f.labels
|
|
621
|
+
if (f.note && /unhandled/.test(f.note)) unhandled += 1
|
|
622
|
+
}
|
|
623
|
+
unhandled += run.skipped.filter(s => /unhandled/.test(s.reason)).length
|
|
624
|
+
}
|
|
625
|
+
return { files, labels, unhandled }
|
|
626
|
+
}
|
|
627
|
+
|
|
575
628
|
// Fails closed below the search-accept threshold. A wrong merge destroys BOTH
|
|
576
629
|
// identities at once and cannot be undone from the store alone, so the only
|
|
577
630
|
// acceptable evidence is acoustic. `force` exists for the case where Miles
|
|
@@ -605,10 +658,18 @@ voiceRouter.post('/voice/merge-profiles', (req, res) => {
|
|
|
605
658
|
|
|
606
659
|
if (req.body?.confirm !== true && !dryRun) {
|
|
607
660
|
const preview = mergeSpeakerProfiles(into, from, { force, dryRun: true })
|
|
661
|
+
// BLAST RADIUS IN THE PREVIEW, not after the fact. The confirm gate exists so a
|
|
662
|
+
// human sees what a merge does before it happens, and rewriting production
|
|
663
|
+
// meeting records is the largest thing it does. Scoped to the names that would
|
|
664
|
+
// ACTUALLY merge, so a refused pair does not advertise a rewrite that will not
|
|
665
|
+
// run. This walks the meeting library, which is why it happens here -- once, on a
|
|
666
|
+
// deliberate human action -- and not on any hot path.
|
|
667
|
+
const fan = fanOutMergeToMeetings(preview.merged, into, false)
|
|
608
668
|
return res.status(400).json({
|
|
609
669
|
error: 'confirmation required',
|
|
610
670
|
message: `Merging is not reversible from the store alone. Review the similarity scores, then pass { confirm: true }.`,
|
|
611
671
|
preview,
|
|
672
|
+
meetingRewrite: { ...fanOutTotals(fan.runs), dryRun: true, error: fan.error },
|
|
612
673
|
})
|
|
613
674
|
}
|
|
614
675
|
|
|
@@ -638,7 +699,27 @@ voiceRouter.post('/voice/merge-profiles', (req, res) => {
|
|
|
638
699
|
}
|
|
639
700
|
|
|
640
701
|
if (!dryRun) invalidateVoiceDirectory()
|
|
641
|
-
|
|
702
|
+
|
|
703
|
+
// The fan-out honours `dryRun` for the same reason the merge does: a dry run must
|
|
704
|
+
// stay a dry run all the way down, or `dryRun: true` becomes the most dangerous
|
|
705
|
+
// parameter in the API.
|
|
706
|
+
const fan = fanOutMergeToMeetings(report.merged, into, !dryRun)
|
|
707
|
+
res.json({
|
|
708
|
+
...report,
|
|
709
|
+
dryRun,
|
|
710
|
+
forced: force,
|
|
711
|
+
calibrationRowsRelabeled: calibration,
|
|
712
|
+
meetingRewrite: {
|
|
713
|
+
...fanOutTotals(fan.runs),
|
|
714
|
+
dryRun,
|
|
715
|
+
error: fan.error,
|
|
716
|
+
// Paths, not just counts: this rewrote production records and the operator
|
|
717
|
+
// should be able to see exactly which, and diff them.
|
|
718
|
+
sidecars: fan.runs.flatMap(r => r.sidecars),
|
|
719
|
+
markdown: fan.runs.flatMap(r => r.markdown),
|
|
720
|
+
skipped: fan.runs.flatMap(r => r.skipped),
|
|
721
|
+
},
|
|
722
|
+
})
|
|
642
723
|
} catch (err: unknown) {
|
|
643
724
|
res.status(500).json({ error: errMsg(err) })
|
|
644
725
|
}
|