@gotcos/glasses-server 6.44.13 → 6.44.15

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 CHANGED
@@ -1,3 +1,63 @@
1
+ ## 6.44.15
2
+
3
+ Voice profiles named after a spoken sentence are repaired at load.
4
+
5
+ - Chelsie 2026-09-08: her `voice-profiles.json` held two profiles whose name
6
+ was the entire enrolment speech ("My Voice My Name Is Chelsie Hodgkiss And I
7
+ Am The Director…", ~600 characters), written by a client older than the
8
+ 8/25 guards. Speaker ID was "active" and could never match anything, and the
9
+ file cannot be repaired by hand because the server rewrites it from memory.
10
+ - `normalizeProfileStore` now renames a name that is too long, has more than
11
+ four words, or is three-plus words with sentence punctuation to
12
+ `Unnamed voice N`, keeps every embedding and its provenance, records
13
+ `renamedFrom` (the first 60 characters) and `needsName: true`, and counts it
14
+ in the load repairs (`profilesRenamed`). Legacy short labels ("MU",
15
+ "Speaker 2", "Luke H.") are never touched. `GET /api/voice/profiles` carries
16
+ `needsName` and `renamedFrom` so COS Control's speaker review can offer the
17
+ rename; `POST /api/voice/merge-profiles` folds the placeholder into the
18
+ right person as before.
19
+
20
+ ## 6.44.14
21
+
22
+ The Manage sheet's merge, with a preview, a worker and a receipt, plus a
23
+ duplicates scan that proposes and never merges.
24
+
25
+ - Miles 2026-09-08: "Build the Manage merge path with the preview", and
26
+ "address any of the obvious duplicates like the miels and queen example
27
+ from above without clobbering entities. You did good in deflecting miles
28
+ mallard vs the other miles profiles."
29
+ - `POST /api/context/graph/merge/preview` `{ source, target }`: both entities
30
+ from this Mac's index, shared neighbors, the effect (relationships moved and
31
+ collapsed, texts re-embedded, about how long), a name signal, warnings
32
+ (types differ, larger into smaller, nothing links them, a large merge), and
33
+ `blocked` with its reason for a pair the graph knows to be different people
34
+ (Miles Ukaoma / Miles Mallard, Manoj Bisht / Manoj Kumar, the Kyles, the
35
+ Jacobuses, and any pair the user kept apart).
36
+ - `POST /api/context/graph/merge` `{ source, target, confirm: true, rule? }`:
37
+ starts ONE detached worker on the ingestion owner and answers 202 with a
38
+ ticket and the receipt. The worker takes the exclusive ingest lock, copies
39
+ the GraphML aside, runs LightRAG's own entity merge (no deprecated strategy
40
+ argument), appends a curation ledger row, rebuilds the per-Mac index and
41
+ the Observatory export, and stamps its receipt at every step. Measured: the
42
+ hand merges of 2026-09-08 took about two minutes, so this is never a request.
43
+ Without `confirm` the answer is 400 `confirmation_required` with the preview;
44
+ 409 `merge_blocked`, `merge_running`, `lock_held`, `not_owner`, or
45
+ `embedding_not_ready` (a merge re-embeds 1 + degree texts).
46
+ - `GET /api/context/graph/merge`: the receipt with the worker's log tail. A
47
+ worker that died reads as failed with the snapshot intact, never as running.
48
+ - `GET /api/context/graph/duplicates?limit=`: person entities whose names are
49
+ variants of one another (same first name with a surname within two letters,
50
+ whole names within two letters, one name spelling out the other, a bare
51
+ first name that matches exactly one full name), grouped under the full name
52
+ with the most connections, with shared-neighbor counts and a confidence.
53
+ Possessives ("Miles Ukaoma's Son") and ambiguous first names are left alone;
54
+ blocked pairs never share a group. Nothing here merges.
55
+ - Four bridge commands: `graph-merge-preview`, `graph-merge`,
56
+ `graph-merge-status`, `graph-duplicates` (28 in the parity set).
57
+ - Test: the 6.44.13 guardrails-run pin expected a zone-less `created_at` to
58
+ read as null; the normalizer has always passed a parseable timestamp through,
59
+ and the pin failed at HEAD. Corrected to the passed-through value.
60
+
1
61
  ## 6.44.13
2
62
 
3
63
  Prune or accept a captured memory, and guardrails that prune nonsense for you.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.13",
3
+ "version": "6.44.15",
4
4
  "description": "COS Glasses \u2014 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": {
@@ -1121,3 +1121,120 @@ export function normalizeContextBrowserStatus(value: unknown): ContextBrowserSta
1121
1121
  ...(graph ? { graph } : {}),
1122
1122
  }
1123
1123
  }
1124
+
1125
+ // ── Curation (6.44.14): merge preview, the merge worker's receipt, duplicate proposals ──
1126
+
1127
+ export const MERGE_NAME_LIMIT = 200
1128
+ export const DUPLICATES_LIMIT_MAX = 100
1129
+ export const MERGE_RECEIPT_STATES = ['running', 'merging', 'indexing', 'exporting', 'done', 'failed'] as const
1130
+ export type MergeReceiptState = typeof MERGE_RECEIPT_STATES[number]
1131
+
1132
+ function entityCard(value: unknown, fallbackId: string): Record<string, unknown> {
1133
+ const r = asRecord(value) ?? {}
1134
+ return {
1135
+ id: stringOrAbsent(r.id, MERGE_NAME_LIMIT) ?? fallbackId,
1136
+ found: r.found === true,
1137
+ type: stringOrAbsent(r.type, 40) ?? null,
1138
+ degree: integerOrAbsent(r.degree) ?? 0,
1139
+ descriptions: (Array.isArray(r.descriptions) ? r.descriptions : []).filter((d): d is string => typeof d === 'string').map(d => cleanContextText(d, 300)).filter(Boolean).slice(0, 3),
1140
+ description_count: integerOrAbsent(r.description_count) ?? 0,
1141
+ created_at: integerOrAbsent(r.created_at) ?? null,
1142
+ }
1143
+ }
1144
+
1145
+ /** `graph-merge-preview`: what a merge would do, read from the index. `blocked` wins over every warning. */
1146
+ export function normalizeMergePreview(value: unknown, source = '', target = ''): Record<string, unknown> {
1147
+ const s = asRecord(value) ?? {}
1148
+ const effect = asRecord(s.effect)
1149
+ return {
1150
+ available: s.available === true,
1151
+ index_state: stringOrAbsent(s.index_state, 20) ?? 'missing',
1152
+ index_built_at: isoOrAbsent(s.index_built_at) ?? null,
1153
+ source: entityCard(s.source, source),
1154
+ target: entityCard(s.target, target),
1155
+ shared_neighbors: (Array.isArray(s.shared_neighbors) ? s.shared_neighbors : []).filter((n): n is string => typeof n === 'string').map(n => cleanContextText(n, MERGE_NAME_LIMIT)).filter(Boolean).slice(0, 10),
1156
+ shared_count: integerOrAbsent(s.shared_count) ?? 0,
1157
+ adjacent: s.adjacent === true,
1158
+ effect: effect ? { moved: integerOrAbsent(effect.moved) ?? 0, collapsed: integerOrAbsent(effect.collapsed) ?? 0, embeddings: integerOrAbsent(effect.embeddings) ?? 0, estimated_seconds: integerOrAbsent(effect.estimated_seconds) ?? null } : null,
1159
+ name_signal: stringOrAbsent(s.name_signal, 80) ?? null,
1160
+ blocked: s.blocked === true,
1161
+ block_reason: stringOrAbsent(s.block_reason, 300) ?? null,
1162
+ warnings: (Array.isArray(s.warnings) ? s.warnings : []).flatMap((w) => {
1163
+ const r = asRecord(w); const code = r ? stringOrAbsent(r.code, 40) : undefined; const text = r ? stringOrAbsent(r.text, 300) : undefined
1164
+ return code && text ? [{ code, text }] : []
1165
+ }).slice(0, 8),
1166
+ }
1167
+ }
1168
+
1169
+ /** The worker's receipt: one merge per Mac at a time, every step stamped. */
1170
+ export function normalizeMergeReceipt(value: unknown): Record<string, unknown> | null {
1171
+ const r = asRecord(value)
1172
+ if (!r) return null
1173
+ const state = typeof r.state === 'string' && (MERGE_RECEIPT_STATES as readonly string[]).includes(r.state) ? r.state as MergeReceiptState : null
1174
+ if (!state) return null
1175
+ const before = asRecord(r.before); const after = asRecord(r.after)
1176
+ return {
1177
+ ticket: stringOrAbsent(r.ticket, 40) ?? null,
1178
+ state,
1179
+ step: stringOrAbsent(r.step, 20) ?? null,
1180
+ source: stringOrAbsent(r.source, MERGE_NAME_LIMIT) ?? null,
1181
+ target: stringOrAbsent(r.target, MERGE_NAME_LIMIT) ?? null,
1182
+ by: stringOrAbsent(r.by, 40) ?? null,
1183
+ pid: integerOrAbsent(r.pid) ?? null,
1184
+ started_at: isoOrAbsent(r.started_at) ?? null,
1185
+ finished_at: isoOrAbsent(r.finished_at) ?? null,
1186
+ elapsed_s: typeof r.elapsed_s === 'number' && Number.isFinite(r.elapsed_s) ? r.elapsed_s : null,
1187
+ before: before ? { source: integerOrAbsent(before.source) ?? null, target: integerOrAbsent(before.target) ?? null } : null,
1188
+ after: after ? { target: integerOrAbsent(after.target) ?? null, source_present: after.source_present === true } : null,
1189
+ embedded_texts: integerOrAbsent(r.embedded_texts) ?? null,
1190
+ snapshot: stringOrAbsent(r.snapshot, 400) ?? null,
1191
+ rule: asRecord(r.rule) ? { scope: stringOrAbsent((r.rule as Record<string, unknown>).scope, 40) ?? null, pattern: stringOrAbsent((r.rule as Record<string, unknown>).pattern, MERGE_NAME_LIMIT) ?? null, replacement: stringOrAbsent((r.rule as Record<string, unknown>).replacement, MERGE_NAME_LIMIT) ?? null } : null,
1192
+ error: stringOrAbsent(r.error, 400) ?? null,
1193
+ export_note: stringOrAbsent(r.export_note, 200) ?? null,
1194
+ }
1195
+ }
1196
+
1197
+ /** `graph-merge` (202) and `graph-merge-status`: the receipt plus the worker's log tail. */
1198
+ export function normalizeMergeStatus(value: unknown): Record<string, unknown> {
1199
+ const s = asRecord(value) ?? {}
1200
+ return {
1201
+ running: s.running === true,
1202
+ receipt: normalizeMergeReceipt(s.receipt),
1203
+ log_tail: (Array.isArray(s.log_tail) ? s.log_tail : []).filter((l): l is string => typeof l === 'string').map(l => l.slice(0, 300)).slice(-24),
1204
+ }
1205
+ }
1206
+
1207
+ export function normalizeMergeKickoff(value: unknown): Record<string, unknown> {
1208
+ const s = asRecord(value) ?? {}
1209
+ return {
1210
+ started: s.started === true,
1211
+ ticket: stringOrAbsent(s.ticket, 40) ?? null,
1212
+ pid: integerOrAbsent(s.pid) ?? null,
1213
+ estimated_seconds: integerOrAbsent(s.estimated_seconds) ?? null,
1214
+ receipt: normalizeMergeReceipt(s.receipt),
1215
+ }
1216
+ }
1217
+
1218
+ /** `graph-duplicates`: person entities whose names look like one person. Proposals, never merges. */
1219
+ export function normalizeDuplicates(value: unknown): Record<string, unknown> {
1220
+ const s = asRecord(value) ?? {}
1221
+ const groups = (Array.isArray(s.groups) ? s.groups : []).flatMap((g) => {
1222
+ const r = asRecord(g); const target = r ? stringOrAbsent(r.target, MERGE_NAME_LIMIT) : undefined
1223
+ if (!r || !target) return []
1224
+ const members = (Array.isArray(r.members) ? r.members : []).flatMap((m) => {
1225
+ const mr = asRecord(m); const id = mr ? stringOrAbsent(mr.id, MERGE_NAME_LIMIT) : undefined
1226
+ if (!mr || !id) return []
1227
+ return [{ id, degree: integerOrAbsent(mr.degree) ?? 0, shared_neighbors: integerOrAbsent(mr.shared_neighbors) ?? 0, description: stringOrAbsent(mr.description, 300) ?? '', why: stringOrAbsent(mr.why, 80) ?? null }]
1228
+ }).slice(0, 6)
1229
+ const confidence = r.confidence === 'high' || r.confidence === 'medium' || r.confidence === 'low' ? r.confidence : 'low'
1230
+ return members.length >= 2 ? [{ target, members, confidence, reasons: (Array.isArray(r.reasons) ? r.reasons : []).filter((x): x is string => typeof x === 'string').map(x => cleanContextText(x, 80)).slice(0, 6), linked: r.linked === true }] : []
1231
+ }).slice(0, DUPLICATES_LIMIT_MAX)
1232
+ return {
1233
+ available: s.available === true,
1234
+ index_state: stringOrAbsent(s.index_state, 20) ?? 'missing',
1235
+ index_built_at: isoOrAbsent(s.index_built_at) ?? null,
1236
+ scanned: integerOrAbsent(s.scanned) ?? 0,
1237
+ total_groups: integerOrAbsent(s.total_groups) ?? groups.length,
1238
+ groups,
1239
+ }
1240
+ }
@@ -70,6 +70,10 @@ export const LEARNING_COMMANDS = [
70
70
  'memory-review',
71
71
  'memory-guardrails',
72
72
  'memory-guardrails-run',
73
+ 'graph-merge-preview',
74
+ 'graph-merge',
75
+ 'graph-merge-status',
76
+ 'graph-duplicates',
73
77
  ] as const
74
78
 
75
79
  // The optional Python bridge is available only when the user points us at a real
@@ -256,6 +260,10 @@ function standaloneNoop(args: string[]): unknown {
256
260
  case 'memory-review':
257
261
  case 'memory-guardrails':
258
262
  case 'memory-guardrails-run':
263
+ case 'graph-merge-preview':
264
+ case 'graph-merge':
265
+ case 'graph-merge-status':
266
+ case 'graph-duplicates':
259
267
  return { error: 'cos_pipeline_not_configured' }
260
268
  case 'task-rows':
261
269
  case 'task-capture':
@@ -11,6 +11,7 @@
11
11
  // by execution rather than by asserting on source text. speaker-embeddings.ts
12
12
  // delegates to it and keeps the sherpa-onnx manager in sync.
13
13
 
14
+ import { checkSpeakerName, type SpeakerNameRejection } from './speaker-name.js'
14
15
  import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
15
16
  import { basename, dirname, join, resolve } from 'node:path'
16
17
  import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
@@ -21,6 +22,12 @@ export interface VoiceProfile {
21
22
  embeddings: number[][]
22
23
  /** Provenance: 'manual' | 'fireflies' | 'g2-training' | 'auto:<sessionId>' | … */
23
24
  sources?: string[]
25
+ /** The name on disk was a spoken sentence, not a name (an old client's
26
+ * "enroll my voice" fall-through); it was renamed at load and the training
27
+ * kept. COS Control's speaker review shows it for renaming. */
28
+ needsName?: boolean
29
+ /** The first 60 characters of the name it was loaded with. */
30
+ renamedFrom?: string
24
31
  }
25
32
 
26
33
  export interface ProfileStore {
@@ -42,6 +49,9 @@ export interface StoreRepairs {
42
49
  dimensionMismatch: number
43
50
  /** Entries with no usable name, or duplicate names collapsed. */
44
51
  profilesDropped: number
52
+ /** Names that were a whole spoken sentence (too long, too many words, or
53
+ * sentence punctuation), renamed to `Unnamed voice N` with the training kept. */
54
+ profilesRenamed: number
45
55
  }
46
56
 
47
57
  export function emptyRepairs(): StoreRepairs {
@@ -51,17 +61,19 @@ export function emptyRepairs(): StoreRepairs {
51
61
  embeddingsDropped: 0,
52
62
  dimensionMismatch: 0,
53
63
  profilesDropped: 0,
64
+ profilesRenamed: 0,
54
65
  }
55
66
  }
56
67
 
57
68
  export function hasRepairs(r: StoreRepairs): boolean {
58
69
  return r.sourcesRealigned > 0 || r.sourcesCoerced > 0 || r.embeddingsDropped > 0
59
- || r.dimensionMismatch > 0 || r.profilesDropped > 0
70
+ || r.dimensionMismatch > 0 || r.profilesDropped > 0 || r.profilesRenamed > 0
60
71
  }
61
72
 
62
73
  export function describeRepairs(r: StoreRepairs): string {
63
74
  const parts: string[] = []
64
75
  if (r.profilesDropped) parts.push(`${r.profilesDropped} unusable profile(s)`)
76
+ if (r.profilesRenamed) parts.push(`${r.profilesRenamed} profile(s) named after a spoken sentence, renamed for review`)
65
77
  if (r.embeddingsDropped) parts.push(`${r.embeddingsDropped} unusable embedding row(s)`)
66
78
  if (r.sourcesRealigned) parts.push(`${r.sourcesRealigned} profile(s) with misaligned sources[]`)
67
79
  if (r.sourcesCoerced) parts.push(`${r.sourcesCoerced} null/non-string source slot(s)`)
@@ -106,11 +118,27 @@ export function normalizeProfileStore(raw: unknown): { store: ProfileStore; repa
106
118
  if (!Array.isArray(rawProfiles)) return { store: { profiles: [] }, repairs }
107
119
 
108
120
  const byName = new Map<string, VoiceProfile>()
121
+ let unnamed = 0
109
122
  for (const candidate of rawProfiles) {
110
- const name = typeof (candidate as VoiceProfile)?.name === 'string'
123
+ const rawName = typeof (candidate as VoiceProfile)?.name === 'string'
111
124
  ? (candidate as VoiceProfile).name.trim()
112
125
  : ''
113
- if (!name) { repairs.profilesDropped++; continue }
126
+ if (!rawName) { repairs.profilesDropped++; continue }
127
+ // Chelsie's store (2026-09-08) held two profiles named with the entire
128
+ // enrolment speech (~600 characters), written by a client older than the
129
+ // 6.8.433 / server 8/25 guards. A name like that can never match a speaker
130
+ // label, and the file cannot be repaired by hand because the server
131
+ // rewrites it from memory. Keep the training, give it a placeholder name,
132
+ // and flag it so COS Control offers a rename. Only the sentence shapes are
133
+ // renamed; legacy short labels ("MU", "Speaker 2") are left exactly as is.
134
+ const junk = junkNameReason(rawName)
135
+ let renamedFrom: string | undefined
136
+ let name = rawName
137
+ if (junk) {
138
+ do { name = `Unnamed voice ${++unnamed}` } while (byName.has(name) || rawProfiles.some(p => (p as VoiceProfile)?.name === name))
139
+ renamedFrom = rawName.slice(0, 60)
140
+ repairs.profilesRenamed++
141
+ }
114
142
 
115
143
  const rawEmbeddings = Array.isArray((candidate as VoiceProfile).embeddings)
116
144
  ? (candidate as VoiceProfile).embeddings as unknown[]
@@ -154,12 +182,23 @@ export function normalizeProfileStore(raw: unknown): { store: ProfileStore; repa
154
182
  existing.sources!.push(...sources)
155
183
  continue
156
184
  }
157
- byName.set(name, { name, embeddings, sources })
185
+ byName.set(name, { name, embeddings, sources, ...(renamedFrom ? { needsName: true, renamedFrom } : {}) })
158
186
  }
159
187
 
160
188
  return { store: { profiles: [...byName.values()] }, repairs }
161
189
  }
162
190
 
191
+ /** The sentence shapes only: too long, too many words, or sentence punctuation. */
192
+ export function junkNameReason(name: string): SpeakerNameRejection | null {
193
+ const check = checkSpeakerName(name, { ownerLabel: name })
194
+ if (check.ok) return null
195
+ if (check.reason === 'too_long' || check.reason === 'too_many_words') return check.reason
196
+ // Sentence punctuation alone is not enough: "Luke H." is a legitimate legacy
197
+ // alias. Three or more words with punctuation is speech, not a name.
198
+ if (check.reason === 'sentence_like' && name.trim().split(/\s+/).length >= 3) return check.reason
199
+ return null
200
+ }
201
+
163
202
  export type StoreLoad = {
164
203
  store: ProfileStore
165
204
  repairs: StoreRepairs
@@ -34,6 +34,12 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
34
34
  normalizeExtractionBlock,
35
35
  normalizeGuardrails,
36
36
  normalizeGuardrailsRun,
37
+ normalizeMergePreview,
38
+ normalizeMergeStatus,
39
+ normalizeMergeKickoff,
40
+ normalizeDuplicates,
41
+ MERGE_NAME_LIMIT,
42
+ DUPLICATES_LIMIT_MAX,
37
43
  EMBEDDING_PROVIDERS,
38
44
  EXTRACTION_TIERS,
39
45
  KNOWLEDGE_SETUP_SAMPLE_MAX,
@@ -289,6 +295,106 @@ memoryRouter.post('/context/memory-guardrails/run', async (req, res) => {
289
295
  }
290
296
  })
291
297
 
298
+ // ── Curation (6.44.14): the Manage sheet's merge, with a preview, a worker and a receipt ──
299
+ //
300
+ // Miles 2026-09-08: "Build the Manage merge path with the preview" and "address any of
301
+ // the obvious duplicates like the miels and queen example without clobbering entities."
302
+ // The merge is a DETACHED worker: the two hand merges of 2026-09-08 took ~2 minutes of
303
+ // vector-store rewriting, far past any request budget. The kickoff answers 202 with a
304
+ // ticket; GET /context/graph/merge is the receipt to poll. Two people the graph knows
305
+ // to be different (Miles Ukaoma / Miles Mallard) come back 409 merge_blocked.
306
+
307
+ function mergeNames(body: unknown): { source: string; target: string } | null {
308
+ const b = (body ?? {}) as { source?: unknown; target?: unknown }
309
+ const source = typeof b.source === 'string' ? b.source.trim() : ''
310
+ const target = typeof b.target === 'string' ? b.target.trim() : ''
311
+ if (source.length < 1 || source.length > MERGE_NAME_LIMIT || target.length < 1 || target.length > MERGE_NAME_LIMIT) return null
312
+ return { source, target }
313
+ }
314
+
315
+ /** `{ source, target }` → what the merge would do. Read-only; a blocked pair answers 200 with `blocked: true`. */
316
+ memoryRouter.post('/context/graph/merge/preview', async (req, res) => {
317
+ noStore(res)
318
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
319
+ const names = mergeNames(req.body)
320
+ if (!names) { res.status(400).json({ error: 'invalid_entity', message: `source and target must be 1 to ${MERGE_NAME_LIMIT} characters` }); return }
321
+ try {
322
+ const data = await callPython(['graph-merge-preview', `--source=${names.source}`, `--target=${names.target}`], 20_000)
323
+ const code = bridgeErrorCode(data)
324
+ if (code) { sendSetupError(res, code, data); return }
325
+ res.json(normalizeMergePreview(data, names.source, names.target))
326
+ } catch (error) {
327
+ console.warn('[context] merge preview bridge failure:', (error as Error).message)
328
+ res.status(503).json({ error: 'graph_unavailable' })
329
+ }
330
+ })
331
+
332
+ /**
333
+ * `{ source, target, confirm: true, rule? }` → start the merge worker on the owner Mac; 202 with the ticket and receipt.
334
+ * Without `confirm: true` the answer is 400 confirmation_required with the preview. 409 when the pair is blocked, a merge
335
+ * is already running, the ingest lock is held, this is a replica, or the chosen embedding cannot embed right now.
336
+ */
337
+ memoryRouter.post('/context/graph/merge', async (req, res) => {
338
+ noStore(res)
339
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
340
+ const names = mergeNames(req.body)
341
+ if (!names) { res.status(400).json({ error: 'invalid_entity', message: `source and target must be 1 to ${MERGE_NAME_LIMIT} characters` }); return }
342
+ const body = (req.body ?? {}) as { confirm?: unknown; rule?: unknown }
343
+ if (body.confirm !== undefined && typeof body.confirm !== 'boolean') { res.status(400).json({ error: 'invalid_confirm', message: 'confirm must be true or false' }); return }
344
+ if (body.rule !== undefined && body.rule !== null && (typeof body.rule !== 'object' || Array.isArray(body.rule))) { res.status(400).json({ error: 'invalid_rule', message: 'rule must be an object' }); return }
345
+ const argv = ['graph-merge', `--source=${names.source}`, `--target=${names.target}`, '--by=control']
346
+ if (body.confirm === true) argv.push('--confirm')
347
+ if (body.rule && typeof body.rule === 'object') {
348
+ const r = body.rule as Record<string, unknown>
349
+ const rule: Record<string, string> = {}
350
+ for (const key of ['scope', 'pattern', 'replacement']) if (typeof r[key] === 'string') rule[key] = (r[key] as string).slice(0, MERGE_NAME_LIMIT)
351
+ argv.push(`--rule=${JSON.stringify(rule)}`)
352
+ }
353
+ try {
354
+ // 20 s: the kickoff reads the index and probes the embedding, then spawns and answers.
355
+ const data = await callPython(argv, 20_000)
356
+ const code = bridgeErrorCode(data)
357
+ if (code) { sendSetupError(res, code, data); return }
358
+ res.status(202).json(normalizeMergeKickoff(data))
359
+ } catch (error) {
360
+ console.warn('[context] merge bridge failure:', (error as Error).message)
361
+ res.status(503).json({ error: 'graph_unavailable' })
362
+ }
363
+ })
364
+
365
+ /** The current or last merge's receipt with the worker's log tail. A dead worker reads as failed, never as running. */
366
+ memoryRouter.get('/context/graph/merge', async (_req, res) => {
367
+ noStore(res)
368
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
369
+ try {
370
+ const data = await callPython(['graph-merge-status'], 10_000)
371
+ const code = bridgeErrorCode(data)
372
+ if (code) { res.status(503).json({ error: code }); return }
373
+ res.json(normalizeMergeStatus(data))
374
+ } catch (error) {
375
+ console.warn('[context] merge status bridge failure:', (error as Error).message)
376
+ res.status(503).json({ error: 'graph_unavailable' })
377
+ }
378
+ })
379
+
380
+ /** `?limit=` → person entities whose names look like one person, grouped with a confidence. Proposals only. */
381
+ memoryRouter.get('/context/graph/duplicates', async (req, res) => {
382
+ noStore(res)
383
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
384
+ const raw = req.query.limit
385
+ const limit = raw === undefined ? 25 : Number(raw)
386
+ if (!Number.isInteger(limit) || limit < 1 || limit > DUPLICATES_LIMIT_MAX) { res.status(400).json({ error: 'invalid_limit', message: `limit must be an integer from 1 to ${DUPLICATES_LIMIT_MAX}` }); return }
387
+ try {
388
+ const data = await callPython(['graph-duplicates', `--limit=${limit}`], 30_000)
389
+ const code = bridgeErrorCode(data)
390
+ if (code) { sendSetupError(res, code, data); return }
391
+ res.json(normalizeDuplicates(data))
392
+ } catch (error) {
393
+ console.warn('[context] duplicates bridge failure:', (error as Error).message)
394
+ res.status(503).json({ error: 'graph_unavailable' })
395
+ }
396
+ })
397
+
292
398
  memoryRouter.get('/context/learning/:id', async (req, res) => {
293
399
  noStore(res)
294
400
  if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
@@ -467,6 +573,19 @@ function sendSetupError(res: import('express').Response, code: string, data: unk
467
573
  const detail = asDetail(data)
468
574
  if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
469
575
  if (code === 'embedding_locked' || code === 'embedding_mismatch') { res.status(409).json({ error: code, message: detail.message }); return }
576
+ if (code === 'merge_blocked') {
577
+ // 6.44.14: two entities the graph knows to be different people. The preview rides along so the page can say why.
578
+ const d = (typeof data === 'object' && data !== null ? data : {}) as { preview?: unknown }
579
+ res.status(409).json({ error: code, message: detail.message, preview: d.preview ? normalizeMergePreview(d.preview) : null }); return
580
+ }
581
+ if (code === 'merge_running' || code === 'lock_held') {
582
+ const d = (typeof data === 'object' && data !== null ? data : {}) as { receipt?: unknown; lock?: unknown }
583
+ res.status(409).json({ error: code, message: detail.message, receipt: d.receipt ? normalizeMergeStatus({ receipt: d.receipt }).receipt : null, lock: normalizeIngestKickoff({ lock: d.lock }).lock }); return
584
+ }
585
+ if (code === 'confirmation_required') {
586
+ const d = (typeof data === 'object' && data !== null ? data : {}) as { preview?: unknown }
587
+ res.status(400).json({ error: code, message: detail.message, preview: d.preview ? normalizeMergePreview(d.preview) : null }); return
588
+ }
470
589
  if (code === 'embedding_not_ready') {
471
590
  // 6.44.12: the chosen embedding cannot embed on this Mac right now. The
472
591
  // bridge refused before spawning; pass its fix through so the page can show it.
@@ -540,6 +540,10 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
540
540
  embeddings: p.embeddings.length,
541
541
  isOwner: p.name === owner,
542
542
  sources: bySource,
543
+ // 6.44.15: a profile whose name on disk was a spoken sentence was
544
+ // renamed at load; the client offers a rename.
545
+ needsName: p.needsName === true,
546
+ ...(p.renamedFrom ? { renamedFrom: p.renamedFrom } : {}),
543
547
  // Provenance alignment is now an invariant; surfacing it makes a
544
548
  // future regression visible instead of silent.
545
549
  sourcesAligned: (p.sources?.length ?? 0) === p.embeddings.length,