@gotcos/glasses-server 6.44.12 → 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 CHANGED
@@ -1,3 +1,67 @@
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
+
42
+ ## 6.44.13
43
+
44
+ Prune or accept a captured memory, and guardrails that prune nonsense for you.
45
+
46
+ - Miles 2026-09-07, looking at a captured decision that read "AAAA...":
47
+ "we need the ability to either prune or accept the memory", and "an
48
+ automated review ... another person could set their own guardrails that
49
+ automatically just prune out stuff like this."
50
+ - `POST /api/context/memory/:id/review` `{ decision: accept | prune, note? }`
51
+ stamps a captured memory as reviewed or deletes it; either way a review
52
+ ledger row (`accepted`, `pruned`) the learning timeline shows. Both are new
53
+ event types.
54
+ - `GET` and `PUT /api/context/memory-guardrails`: the user's own rules
55
+ (minimum words, distinct characters, a repeat-ratio ceiling, banned
56
+ patterns, and an optional model pass: on or off, the tier, at most N per
57
+ run). The same rules refuse nonsense at capture time in the bridge's COS.
58
+ - `POST /api/context/memory-guardrails/run` `{ days?, apply?, llm? }` scans
59
+ the captured memories, judges them (rules first; the model pass against
60
+ the COS philosophy principles only on what survives, only when enabled),
61
+ and returns every verdict with its reasons. Nothing is deleted without
62
+ `apply`; a record a person accepted is never flagged.
63
+ - Includes 6.44.12, never published.
64
+
1
65
  ## 6.44.12
2
66
 
3
67
  The Knowledge down-select, and a fail-closed embedding gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.12",
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": {
@@ -470,7 +470,8 @@ export function normalizeGraphBlock(value: unknown): GraphBlock | null {
470
470
  // ── Recent learning payloads (GET /context/learning, /context/learning/:id) ──
471
471
 
472
472
  export const LEARNING_EVENT_ID_PATTERN = /^evt_[a-f0-9]{16}$/
473
- export const LEARNING_EVENT_TYPES = new Set(['captured', 'proposed', 'promotable', 'saved', 'retrieved', 'used', 'checked', 'dismissed', 'reverted', 'reopened', 'consolidated', 'previewed'])
473
+ export const LEARNING_EVENT_TYPES = new Set(['captured', 'proposed', 'promotable', 'saved', 'retrieved', 'used', 'checked', 'dismissed', 'reverted', 'reopened', 'consolidated', 'previewed', 'accepted', 'pruned'])
474
+ export const REVIEW_DECISIONS = new Set(['dismissed', 'reopened', 'accepted', 'pruned'])
474
475
  const LEARNING_LIST_LIMIT = 50
475
476
  /** The strict To review set is small (121 today); one page shows it whole. */
476
477
  export const LEARNING_REVIEW_LIMIT = 200
@@ -783,7 +784,7 @@ export function normalizeReviewDecision(value: unknown): Record<string, unknown>
783
784
  if (!row) return null
784
785
  const decision = stringOrAbsent(row.decision, 16)
785
786
  const lessonId = stringOrAbsent(row.lesson_id, 200)
786
- if (!lessonId || (decision !== 'dismissed' && decision !== 'reopened')) return null
787
+ if (!lessonId || !decision || !REVIEW_DECISIONS.has(decision)) return null
787
788
  return {
788
789
  lesson_id: lessonId,
789
790
  decision,
@@ -1018,6 +1019,47 @@ export function normalizeIngestProgress(value: unknown): Record<string, unknown>
1018
1019
  }
1019
1020
  }
1020
1021
 
1022
+ export const GUARDRAIL_LLM_TIERS = ['haiku', 'sonnet', 'opus'] as const
1023
+
1024
+ /** The user's memory guardrails (6.44.13), every field bounded. */
1025
+ export function normalizeGuardrails(value: unknown): Record<string, unknown> | null {
1026
+ const g = asRecord(value)
1027
+ if (!g) return null
1028
+ const llm = asRecord(g.llm_review) ?? {}
1029
+ return {
1030
+ min_words: integerOrAbsent(g.min_words) ?? 6,
1031
+ min_distinct_chars: integerOrAbsent(g.min_distinct_chars) ?? 8,
1032
+ max_repeat_ratio: typeof g.max_repeat_ratio === 'number' && Number.isFinite(g.max_repeat_ratio) ? g.max_repeat_ratio : 0.5,
1033
+ banned_patterns: (Array.isArray(g.banned_patterns) ? g.banned_patterns : []).filter((p): p is string => typeof p === 'string').slice(0, 50).map(p => p.slice(0, 200)),
1034
+ llm_review: {
1035
+ enabled: llm.enabled === true,
1036
+ model: (GUARDRAIL_LLM_TIERS as readonly string[]).includes(String(llm.model)) ? String(llm.model) : 'haiku',
1037
+ max_per_run: integerOrAbsent(llm.max_per_run) ?? 20,
1038
+ },
1039
+ updated_at: isoOrAbsent(g.updated_at) ?? null,
1040
+ by: stringOrAbsent(g.by, 32) ?? null,
1041
+ }
1042
+ }
1043
+
1044
+ /** One guardrails run: the receipt and the verdicts with their reasons. */
1045
+ export function normalizeGuardrailsRun(value: unknown): Record<string, unknown> {
1046
+ const s = asRecord(value) ?? {}
1047
+ const r = asRecord(s.run) ?? {}
1048
+ const verdicts = (Array.isArray(s.verdicts) ? s.verdicts : []).flatMap((row) => {
1049
+ const v = asRecord(row); const id = v ? stringOrAbsent(v.id, 200) : undefined
1050
+ if (!id) return []
1051
+ const verdict = v!.verdict === 'prune' || v!.verdict === 'keep' || v!.verdict === 'review' ? v!.verdict : 'review'
1052
+ return [{ id, type: stringOrAbsent(v!.type, 32) ?? '', created_at: isoOrAbsent(v!.created_at) ?? null, excerpt: typeof v!.excerpt === 'string' ? v!.excerpt.slice(0, 200) : '', verdict,
1053
+ reasons: (Array.isArray(v!.reasons) ? v!.reasons : []).filter((x): x is string => typeof x === 'string').slice(0, 8).map(x => x.slice(0, 300)), by: stringOrAbsent(v!.by, 16) ?? 'rules', applied: v!.applied === true }]
1054
+ }).slice(0, 500)
1055
+ return {
1056
+ run: { id: stringOrAbsent(r.id, 64) ?? null, started_at: isoOrAbsent(r.started_at) ?? null, ended_at: isoOrAbsent(r.ended_at) ?? null, days: integerOrAbsent(r.days) ?? null,
1057
+ scanned: integerOrAbsent(r.scanned) ?? 0, flagged: integerOrAbsent(r.flagged) ?? 0, review: integerOrAbsent(r.review) ?? 0, kept: integerOrAbsent(r.kept) ?? 0,
1058
+ llm_reviewed: integerOrAbsent(r.llm_reviewed) ?? 0, llm_enabled: r.llm_enabled === true, applied: r.applied === true, pruned: integerOrAbsent(r.pruned) ?? 0 },
1059
+ verdicts,
1060
+ }
1061
+ }
1062
+
1021
1063
  export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
1022
1064
  const source = value && typeof value === 'object' && !Array.isArray(value)
1023
1065
  ? value as Record<string, unknown> : {}
@@ -1079,3 +1121,120 @@ export function normalizeContextBrowserStatus(value: unknown): ContextBrowserSta
1079
1121
  ...(graph ? { graph } : {}),
1080
1122
  }
1081
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
+ }
@@ -67,6 +67,13 @@ export const LEARNING_COMMANDS = [
67
67
  'graph-ingest-progress',
68
68
  'graph-setup-embedding',
69
69
  'graph-setup-extraction',
70
+ 'memory-review',
71
+ 'memory-guardrails',
72
+ 'memory-guardrails-run',
73
+ 'graph-merge-preview',
74
+ 'graph-merge',
75
+ 'graph-merge-status',
76
+ 'graph-duplicates',
70
77
  ] as const
71
78
 
72
79
  // The optional Python bridge is available only when the user points us at a real
@@ -250,6 +257,13 @@ function standaloneNoop(args: string[]): unknown {
250
257
  case 'graph-ingest-progress':
251
258
  case 'graph-setup-embedding':
252
259
  case 'graph-setup-extraction':
260
+ case 'memory-review':
261
+ case 'memory-guardrails':
262
+ case 'memory-guardrails-run':
263
+ case 'graph-merge-preview':
264
+ case 'graph-merge':
265
+ case 'graph-merge-status':
266
+ case 'graph-duplicates':
253
267
  return { error: 'cos_pipeline_not_configured' }
254
268
  case 'task-rows':
255
269
  case 'task-capture':
@@ -32,6 +32,14 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
32
32
  normalizeIngestProgress,
33
33
  normalizeEmbeddingBlock,
34
34
  normalizeExtractionBlock,
35
+ normalizeGuardrails,
36
+ normalizeGuardrailsRun,
37
+ normalizeMergePreview,
38
+ normalizeMergeStatus,
39
+ normalizeMergeKickoff,
40
+ normalizeDuplicates,
41
+ MERGE_NAME_LIMIT,
42
+ DUPLICATES_LIMIT_MAX,
35
43
  EMBEDDING_PROVIDERS,
36
44
  EXTRACTION_TIERS,
37
45
  KNOWLEDGE_SETUP_SAMPLE_MAX,
@@ -201,6 +209,192 @@ memoryRouter.post('/context/learning/:id/review', async (req, res) => {
201
209
  }
202
210
  })
203
211
 
212
+ // ── Memory review and guardrails (6.44.13) ──────────────────────────
213
+ //
214
+ // Accept stamps a captured memory as reviewed; prune deletes it. Both are
215
+ // review-ledger rows the timeline shows. The guardrails are the user's own
216
+ // rules; a run scans, judges (rules, then an optional bounded model pass),
217
+ // and prunes only with apply.
218
+
219
+ memoryRouter.post('/context/memory/:id/review', async (req, res) => {
220
+ noStore(res)
221
+ const memoryId = String(req.params.id)
222
+ const decision = typeof req.body?.decision === 'string' ? req.body.decision : ''
223
+ if (!memoryId || memoryId.length > 200 || CONTROL_CHARACTER.test(memoryId)) { res.status(400).json({ error: 'invalid_memory_id' }); return }
224
+ if (decision !== 'accept' && decision !== 'prune') { res.status(400).json({ error: 'invalid_decision', message: 'decision must be accept or prune' }); return }
225
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
226
+ try {
227
+ const note = typeof req.body?.note === 'string' ? req.body.note.slice(0, 400) : ''
228
+ const answer = await callPython(['memory-review', `--id=${memoryId}`, `--decision=${decision}`, ...(note ? [`--note=${note}`] : [])], 20_000)
229
+ const code = bridgeErrorCode(answer)
230
+ if (code) { sendSetupError(res, code, answer); return }
231
+ const a = answer as { decision?: unknown; deleted?: unknown }
232
+ const row = normalizeReviewDecision({ decision: a.decision })
233
+ if (!row) { res.status(503).json({ error: 'memory_review_unavailable' }); return }
234
+ res.json({ decision: row, deleted: a.deleted === true })
235
+ } catch (error) {
236
+ console.warn('[context] memory review bridge failure:', (error as Error).message)
237
+ res.status(503).json({ error: 'memory_unavailable' })
238
+ }
239
+ })
240
+
241
+ memoryRouter.get('/context/memory-guardrails', async (_req, res) => {
242
+ noStore(res)
243
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
244
+ try {
245
+ const answer = await callPython(['memory-guardrails'], 15_000)
246
+ const code = bridgeErrorCode(answer)
247
+ if (code) { sendSetupError(res, code, answer); return }
248
+ const a = answer as { guardrails?: unknown; philosophy_rubric_lines?: unknown }
249
+ res.json({ guardrails: normalizeGuardrails(a.guardrails), philosophy_rubric_lines: Number.isInteger(a.philosophy_rubric_lines) ? a.philosophy_rubric_lines : 0 })
250
+ } catch (error) {
251
+ console.warn('[context] guardrails bridge failure:', (error as Error).message)
252
+ res.status(503).json({ error: 'memory_unavailable' })
253
+ }
254
+ })
255
+
256
+ memoryRouter.put('/context/memory-guardrails', async (req, res) => {
257
+ noStore(res)
258
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
259
+ const body = req.body
260
+ if (!body || typeof body !== 'object' || Array.isArray(body)) { res.status(400).json({ error: 'invalid_guardrails', message: 'the body must be a JSON object' }); return }
261
+ const text = JSON.stringify(body)
262
+ if (text.length > 16_000) { res.status(400).json({ error: 'invalid_guardrails', message: 'the guardrails patch is too large' }); return }
263
+ try {
264
+ const answer = await callPython(['memory-guardrails', '--stdin'], 15_000, text)
265
+ const code = bridgeErrorCode(answer)
266
+ if (code) { sendSetupError(res, code, answer); return }
267
+ const a = answer as { guardrails?: unknown; philosophy_rubric_lines?: unknown }
268
+ res.json({ guardrails: normalizeGuardrails(a.guardrails), philosophy_rubric_lines: Number.isInteger(a.philosophy_rubric_lines) ? a.philosophy_rubric_lines : 0 })
269
+ } catch (error) {
270
+ console.warn('[context] guardrails bridge failure:', (error as Error).message)
271
+ res.status(503).json({ error: 'memory_unavailable' })
272
+ }
273
+ })
274
+
275
+ /** `{ days?, apply?, llm? }` → scan the captured memories; prune the flagged ones only with apply. Bounded: 30 days, one model pass of max_per_run. */
276
+ memoryRouter.post('/context/memory-guardrails/run', async (req, res) => {
277
+ noStore(res)
278
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
279
+ const body = (req.body ?? {}) as { days?: unknown; apply?: unknown; llm?: unknown }
280
+ const days = body.days === undefined || body.days === null ? 30 : Number(body.days)
281
+ if (!Number.isInteger(days) || days < 1 || days > 3650) { res.status(400).json({ error: 'invalid_days', message: 'days must be an integer from 1 to 3650' }); return }
282
+ if (body.apply !== undefined && typeof body.apply !== 'boolean') { res.status(400).json({ error: 'invalid_apply', message: 'apply must be true or false' }); return }
283
+ if (body.llm !== undefined && body.llm !== null && typeof body.llm !== 'boolean') { res.status(400).json({ error: 'invalid_llm', message: 'llm must be true or false' }); return }
284
+ const argv = ['memory-guardrails-run', `--days=${days}`]
285
+ if (body.apply === true) argv.push('--apply')
286
+ if (typeof body.llm === 'boolean') argv.push(`--llm=${body.llm}`)
287
+ try {
288
+ const answer = await callPython(argv, body.llm === false ? 60_000 : 400_000)
289
+ const code = bridgeErrorCode(answer)
290
+ if (code) { sendSetupError(res, code, answer); return }
291
+ res.json(normalizeGuardrailsRun(answer))
292
+ } catch (error) {
293
+ console.warn('[context] guardrails run bridge failure:', (error as Error).message)
294
+ res.status(503).json({ error: 'memory_unavailable' })
295
+ }
296
+ })
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
+
204
398
  memoryRouter.get('/context/learning/:id', async (req, res) => {
205
399
  noStore(res)
206
400
  if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
@@ -379,6 +573,19 @@ function sendSetupError(res: import('express').Response, code: string, data: unk
379
573
  const detail = asDetail(data)
380
574
  if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
381
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
+ }
382
589
  if (code === 'embedding_not_ready') {
383
590
  // 6.44.12: the chosen embedding cannot embed on this Mac right now. The
384
591
  // bridge refused before spawning; pass its fix through so the page can show it.