@gotcos/glasses-server 6.44.13 → 6.44.14
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 +41 -0
- package/package.json +1 -1
- package/server/lib/cos-context-browser.ts +117 -0
- package/server/lib/python-bridge.ts +8 -0
- package/server/routes/memory.ts +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
+
## 6.44.14
|
|
2
|
+
|
|
3
|
+
The Manage sheet's merge, with a preview, a worker and a receipt, plus a
|
|
4
|
+
duplicates scan that proposes and never merges.
|
|
5
|
+
|
|
6
|
+
- Miles 2026-09-08: "Build the Manage merge path with the preview", and
|
|
7
|
+
"address any of the obvious duplicates like the miels and queen example
|
|
8
|
+
from above without clobbering entities. You did good in deflecting miles
|
|
9
|
+
mallard vs the other miles profiles."
|
|
10
|
+
- `POST /api/context/graph/merge/preview` `{ source, target }`: both entities
|
|
11
|
+
from this Mac's index, shared neighbors, the effect (relationships moved and
|
|
12
|
+
collapsed, texts re-embedded, about how long), a name signal, warnings
|
|
13
|
+
(types differ, larger into smaller, nothing links them, a large merge), and
|
|
14
|
+
`blocked` with its reason for a pair the graph knows to be different people
|
|
15
|
+
(Miles Ukaoma / Miles Mallard, Manoj Bisht / Manoj Kumar, the Kyles, the
|
|
16
|
+
Jacobuses, and any pair the user kept apart).
|
|
17
|
+
- `POST /api/context/graph/merge` `{ source, target, confirm: true, rule? }`:
|
|
18
|
+
starts ONE detached worker on the ingestion owner and answers 202 with a
|
|
19
|
+
ticket and the receipt. The worker takes the exclusive ingest lock, copies
|
|
20
|
+
the GraphML aside, runs LightRAG's own entity merge (no deprecated strategy
|
|
21
|
+
argument), appends a curation ledger row, rebuilds the per-Mac index and
|
|
22
|
+
the Observatory export, and stamps its receipt at every step. Measured: the
|
|
23
|
+
hand merges of 2026-09-08 took about two minutes, so this is never a request.
|
|
24
|
+
Without `confirm` the answer is 400 `confirmation_required` with the preview;
|
|
25
|
+
409 `merge_blocked`, `merge_running`, `lock_held`, `not_owner`, or
|
|
26
|
+
`embedding_not_ready` (a merge re-embeds 1 + degree texts).
|
|
27
|
+
- `GET /api/context/graph/merge`: the receipt with the worker's log tail. A
|
|
28
|
+
worker that died reads as failed with the snapshot intact, never as running.
|
|
29
|
+
- `GET /api/context/graph/duplicates?limit=`: person entities whose names are
|
|
30
|
+
variants of one another (same first name with a surname within two letters,
|
|
31
|
+
whole names within two letters, one name spelling out the other, a bare
|
|
32
|
+
first name that matches exactly one full name), grouped under the full name
|
|
33
|
+
with the most connections, with shared-neighbor counts and a confidence.
|
|
34
|
+
Possessives ("Miles Ukaoma's Son") and ambiguous first names are left alone;
|
|
35
|
+
blocked pairs never share a group. Nothing here merges.
|
|
36
|
+
- Four bridge commands: `graph-merge-preview`, `graph-merge`,
|
|
37
|
+
`graph-merge-status`, `graph-duplicates` (28 in the parity set).
|
|
38
|
+
- Test: the 6.44.13 guardrails-run pin expected a zone-less `created_at` to
|
|
39
|
+
read as null; the normalizer has always passed a parseable timestamp through,
|
|
40
|
+
and the pin failed at HEAD. Corrected to the passed-through value.
|
|
41
|
+
|
|
1
42
|
## 6.44.13
|
|
2
43
|
|
|
3
44
|
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.
|
|
3
|
+
"version": "6.44.14",
|
|
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':
|
package/server/routes/memory.ts
CHANGED
|
@@ -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.
|