@gotcos/glasses-server 6.44.10 → 6.44.12

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,48 @@
1
+ ## 6.44.12
2
+
3
+ The Knowledge down-select, and a fail-closed embedding gate.
4
+
5
+ - Miles 2026-09-07: the local embeddings are the free path for people who
6
+ run COS without an OpenAI key, and the setup's job is to help each person
7
+ down-select. The `embedding` block now carries `preference.local_only`
8
+ (the one question the user answers: may text leave this Mac?),
9
+ `recommended` (which provider to mark and the one-sentence reason: a key
10
+ plus the cloud allowed recommends OpenAI large; stay local recommends
11
+ Local premium when Ollama is running on the Mac, else Local light), and
12
+ `ready`/`fix` for the chosen provider. Every provider row carries
13
+ `present` (the key, Ollama, or the engine is there) and `fix` (the
14
+ sentence that makes it ready) beside `ready`.
15
+ - `POST /api/context/graph/setup/embedding` accepts `local_only` (boolean),
16
+ alone or with `provider`; alone it moves the Recommended mark and changes
17
+ no choice. One of `provider` or `local_only` is required.
18
+ - Every indexing kickoff (`POST /api/context/graph/ingest`, `/setup/sample`)
19
+ answers 409 `embedding_not_ready` `{ provider, fix, message }` when the
20
+ chosen embedding cannot embed on this Mac right now (no key, Ollama not
21
+ running or the model not pulled, fastembed absent), instead of spawning an
22
+ indexer that dies in its log. A status block without `ready` counts as
23
+ not ready.
24
+ - The ingest kickoff's bridge budget is 20 s, not 5: the readiness gate probes
25
+ Ollama before it spawns, and the spawn still returns at once.
26
+
27
+ ## 6.44.11
28
+
29
+ Choose how Knowledge indexes: the embedding and the extraction tier.
30
+
31
+ - The setup checklist carries an `embedding` block (the chosen provider,
32
+ model and dimensions; whether the graph is locked to it; every provider's
33
+ readiness on this Mac; a fetch receipt) and an `extraction` block (the
34
+ tier and the three choices).
35
+ - `POST /api/context/graph/setup/embedding` `{ provider, model?, fetch? }`
36
+ chooses OpenAI large (the historical default), OpenAI small, a local premium
37
+ model through Ollama, or a local light model through fastembed/ONNX, and can
38
+ start the local model's fetch. One choice serves LightRAG and every Qdrant
39
+ collection. A graph built with another embedding answers 409
40
+ `embedding_locked`, and every indexing kickoff answers `embedding_mismatch`
41
+ rather than opening a dimension-bound store with the wrong vectors.
42
+ - `POST /api/context/graph/setup/extraction` `{ tier }` chooses Fast (Haiku),
43
+ Balanced (Sonnet) or Deep (Opus) for entity extraction; it takes effect on
44
+ the next run.
45
+
1
46
  ## 6.44.10
2
47
 
3
48
  Ingest progress for COS Control.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.10",
3
+ "version": "6.44.12",
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": {
@@ -853,6 +853,67 @@ function chosenPathOrAbsent(value: unknown, max = 1000): string | undefined {
853
853
  export const KNOWLEDGE_ASK_MAX_CHARS = 400
854
854
 
855
855
  export interface KnowledgeSetupCheck { id: string; ok: boolean; detail: string }
856
+
857
+ export const EMBEDDING_PROVIDERS = ['openai-large', 'openai-small', 'ollama', 'onnx'] as const
858
+ export const EXTRACTION_TIERS = ['haiku', 'sonnet', 'opus'] as const
859
+
860
+ /** `embedding` block of the setup status (6.44.11): the choice, the lock, every provider's readiness. */
861
+ export function normalizeEmbeddingBlock(value: unknown): Record<string, unknown> | null {
862
+ const e = asRecord(value)
863
+ if (!e) return null
864
+ const manifest = asRecord(e.manifest)
865
+ const fetch = asRecord(e.fetch)
866
+ const providers = (Array.isArray(e.providers) ? e.providers : []).flatMap((row) => {
867
+ const r = asRecord(row); const id = r ? stringOrAbsent(r.id, 24) : undefined
868
+ if (!id) return []
869
+ return [{
870
+ id, label: stringOrAbsent(r!.label, 40) ?? id, model: stringOrAbsent(r!.model, 120) ?? '', dimensions: integerOrAbsent(r!.dimensions) ?? null,
871
+ kind: r!.kind === 'local' ? 'local' : 'cloud', cost: stringOrAbsent(r!.cost, 200) ?? '', needs: stringOrAbsent(r!.needs, 200) ?? '',
872
+ ready: r!.ready === true, present: r!.present === true, detail: stringOrAbsent(r!.detail, 200) ?? '',
873
+ fix: stringOrAbsent(r!.fix, 300) ?? null, selected: r!.selected === true,
874
+ }]
875
+ })
876
+ // 6.44.12: the down-select. `preference.local_only` is the one question the
877
+ // user answers (may text leave this Mac?), `recommended` the mark it moves,
878
+ // `ready`/`fix` whether the chosen provider can embed right now and what
879
+ // would make it so. Absent on an older bridge: null, never a guess.
880
+ const preference = asRecord(e.preference)
881
+ const recommended = asRecord(e.recommended)
882
+ const recommendedId = recommended ? stringOrAbsent(recommended.id, 24) : undefined
883
+ return {
884
+ ready: e.ready === true,
885
+ fix: stringOrAbsent(e.fix, 300) ?? null,
886
+ preference: { local_only: preference && typeof preference.local_only === 'boolean' ? preference.local_only : null },
887
+ recommended: recommendedId ? {
888
+ id: recommendedId, label: stringOrAbsent(recommended!.label, 40) ?? recommendedId, reason: stringOrAbsent(recommended!.reason, 300) ?? '',
889
+ local_only: typeof recommended!.local_only === 'boolean' ? recommended!.local_only : null,
890
+ key_present: recommended!.key_present === true, ollama_present: recommended!.ollama_present === true,
891
+ } : null,
892
+ provider: stringOrAbsent(e.provider, 24) ?? null,
893
+ label: stringOrAbsent(e.label, 40) ?? null,
894
+ model: stringOrAbsent(e.model, 120) ?? null,
895
+ dimensions: integerOrAbsent(e.dimensions) ?? null,
896
+ kind: e.kind === 'local' ? 'local' : 'cloud',
897
+ cost: stringOrAbsent(e.cost, 200) ?? null,
898
+ chosen_at: isoOrAbsent(e.chosen_at) ?? null,
899
+ locked: e.locked === true,
900
+ mismatch: e.mismatch === true,
901
+ manifest: manifest ? { provider: stringOrAbsent(manifest.provider, 24) ?? null, model: stringOrAbsent(manifest.model, 120) ?? null, dimensions: integerOrAbsent(manifest.dimensions) ?? null, adopted: manifest.adopted === true } : null,
902
+ providers,
903
+ fetch: fetch ? { provider: stringOrAbsent(fetch.provider, 24) ?? null, model: stringOrAbsent(fetch.model, 120) ?? null, state: stringOrAbsent(fetch.state, 16) ?? 'unknown', pid: integerOrAbsent(fetch.pid) ?? null, started_at: isoOrAbsent(fetch.started_at) ?? null, ended_at: isoOrAbsent(fetch.ended_at) ?? null, error: stringOrAbsent(fetch.error, 300) ?? null } : null,
904
+ }
905
+ }
906
+
907
+ /** `extraction` block: the tier and the three choices. */
908
+ export function normalizeExtractionBlock(value: unknown): Record<string, unknown> | null {
909
+ const x = asRecord(value)
910
+ if (!x) return null
911
+ const tiers = (Array.isArray(x.tiers) ? x.tiers : []).flatMap((row) => {
912
+ const r = asRecord(row); const id = r ? stringOrAbsent(r.id, 16) : undefined
913
+ return id ? [{ id, label: stringOrAbsent(r!.label, 40) ?? id, detail: stringOrAbsent(r!.detail, 200) ?? '', selected: r!.selected === true }] : []
914
+ })
915
+ return { tier: stringOrAbsent(x.tier, 16) ?? null, label: stringOrAbsent(x.label, 40) ?? null, detail: stringOrAbsent(x.detail, 200) ?? null, tiers }
916
+ }
856
917
  export interface KnowledgeSetupSource { path: string; enabled: boolean; exists: boolean; files: number | null; added_at: string | null }
857
918
 
858
919
  /** `graph-setup-status`: the checklist behind the Knowledge setup path. */
@@ -892,6 +953,8 @@ export function normalizeKnowledgeSetup(value: unknown): Record<string, unknown>
892
953
  indexed: integerOrAbsent(sample.indexed) ?? 0,
893
954
  },
894
955
  lock: normalizeIngestKickoff({ lock: s.lock }).lock,
956
+ embedding: normalizeEmbeddingBlock(s.embedding),
957
+ extraction: normalizeExtractionBlock(s.extraction),
895
958
  ask_ready: s.ask_ready === true,
896
959
  protocol: integerOrAbsent(s.protocol) ?? null,
897
960
  }
@@ -65,6 +65,8 @@ export const LEARNING_COMMANDS = [
65
65
  'graph-ask',
66
66
  'graph-schedule',
67
67
  'graph-ingest-progress',
68
+ 'graph-setup-embedding',
69
+ 'graph-setup-extraction',
68
70
  ] as const
69
71
 
70
72
  // The optional Python bridge is available only when the user points us at a real
@@ -246,6 +248,8 @@ function standaloneNoop(args: string[]): unknown {
246
248
  case 'graph-ask':
247
249
  case 'graph-schedule':
248
250
  case 'graph-ingest-progress':
251
+ case 'graph-setup-embedding':
252
+ case 'graph-setup-extraction':
249
253
  return { error: 'cos_pipeline_not_configured' }
250
254
  case 'task-rows':
251
255
  case 'task-capture':
@@ -30,6 +30,10 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
30
30
  normalizeSampleKickoff,
31
31
  normalizeGraphAnswer,
32
32
  normalizeIngestProgress,
33
+ normalizeEmbeddingBlock,
34
+ normalizeExtractionBlock,
35
+ EMBEDDING_PROVIDERS,
36
+ EXTRACTION_TIERS,
33
37
  KNOWLEDGE_SETUP_SAMPLE_MAX,
34
38
  KNOWLEDGE_ASK_MAX_CHARS,
35
39
  normalizeLearningEventDetail,
@@ -331,14 +335,16 @@ memoryRouter.post('/context/graph/ingest', async (req, res) => {
331
335
  return
332
336
  }
333
337
  try {
334
- const data = await callPython(['graph-ingest-start', `--limit=${limit}`, '--reason=control'], 5_000)
338
+ // 20 s, not 5: the kickoff gates on the embedding's readiness, which probes Ollama
339
+ // (2 s bound) before it spawns. The spawn itself still returns at once (QA 2026-09-07).
340
+ const data = await callPython(['graph-ingest-start', `--limit=${limit}`, '--reason=control'], 20_000)
335
341
  const code = bridgeErrorCode(data)
336
342
  if (code === 'not_owner') {
337
343
  const detail = data as { message?: unknown; owner_host?: unknown }
338
344
  res.status(409).json({ error: code, message: typeof detail.message === 'string' ? detail.message : undefined, owner_host: typeof detail.owner_host === 'string' ? detail.owner_host : null })
339
345
  return
340
346
  }
341
- if (code) { res.status(code.startsWith('invalid_') ? 400 : 503).json({ error: code }); return }
347
+ if (code) { sendSetupError(res, code, data); return }
342
348
  res.status(202).json(normalizeIngestKickoff(data))
343
349
  } catch (error) {
344
350
  console.warn('[context] ingest bridge failure:', (error as Error).message)
@@ -372,6 +378,14 @@ memoryRouter.get('/context/graph/ingest/progress', async (_req, res) => {
372
378
  function sendSetupError(res: import('express').Response, code: string, data: unknown): void {
373
379
  const detail = asDetail(data)
374
380
  if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
381
+ if (code === 'embedding_locked' || code === 'embedding_mismatch') { res.status(409).json({ error: code, message: detail.message }); return }
382
+ if (code === 'embedding_not_ready') {
383
+ // 6.44.12: the chosen embedding cannot embed on this Mac right now. The
384
+ // bridge refused before spawning; pass its fix through so the page can show it.
385
+ const d = (typeof data === 'object' && data !== null ? data : {}) as { provider?: unknown; fix?: unknown }
386
+ res.status(409).json({ error: code, message: detail.message, provider: typeof d.provider === 'string' ? d.provider : null, fix: typeof d.fix === 'string' ? d.fix : null })
387
+ return
388
+ }
375
389
  if (code.endsWith('_not_found')) { res.status(404).json({ error: code, message: detail.message }); return }
376
390
  if (code.startsWith('invalid_')) { res.status(400).json({ error: code, message: detail.message }); return }
377
391
  res.status(503).json({ error: code })
@@ -468,6 +482,63 @@ memoryRouter.post('/context/graph/ask', async (req, res) => {
468
482
  }
469
483
  })
470
484
 
485
+ /**
486
+ * `{ provider?, model?, fetch?, local_only? }` → the embedding for every knowledge store (6.44.11). 409 when the graph was built with another.
487
+ * 6.44.12: `local_only` (boolean) records the one question the down-select asks, may text leave this Mac; alone it moves the
488
+ * Recommended mark and changes no choice. One of `provider` or `local_only` is required.
489
+ */
490
+ memoryRouter.post('/context/graph/setup/embedding', async (req, res) => {
491
+ noStore(res)
492
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
493
+ const body = (req.body ?? {}) as { provider?: unknown; model?: unknown; fetch?: unknown; local_only?: unknown }
494
+ const provider = typeof body.provider === 'string' ? body.provider : ''
495
+ const hasPreference = body.local_only !== undefined && body.local_only !== null
496
+ if (hasPreference && typeof body.local_only !== 'boolean') { res.status(400).json({ error: 'invalid_local_only', message: 'local_only must be true or false' }); return }
497
+ if (!provider && !hasPreference) { res.status(400).json({ error: 'invalid_provider', message: `provider must be one of ${EMBEDDING_PROVIDERS.join(', ')}, or local_only must be given` }); return }
498
+ if (provider && !(EMBEDDING_PROVIDERS as readonly string[]).includes(provider)) { res.status(400).json({ error: 'invalid_provider', message: `provider must be one of ${EMBEDDING_PROVIDERS.join(', ')}` }); return }
499
+ const model = typeof body.model === 'string' ? body.model.trim() : ''
500
+ if (model.length > 120 || !/^[A-Za-z0-9._:/-]*$/.test(model)) { res.status(400).json({ error: 'invalid_model', message: 'model names are letters, digits, dots, colons, slashes and dashes' }); return }
501
+ const argv = ['graph-setup-embedding']
502
+ if (provider) argv.push(`--provider=${provider}`)
503
+ if (hasPreference) argv.push(`--local-only=${body.local_only === true ? 'true' : 'false'}`)
504
+ if (model) argv.push(`--model=${model}`)
505
+ if (body.fetch === true) argv.push('--fetch')
506
+ try {
507
+ const data = await callPython(argv, 90_000)
508
+ const code = bridgeErrorCode(data)
509
+ if (code) { sendSetupError(res, code, data); return }
510
+ const d = data as { embedding?: unknown; fetch?: unknown }
511
+ res.json({ embedding: normalizeEmbeddingBlock(d.embedding), fetch: asRecord(d.fetch) ? { started: (d.fetch as { started?: unknown }).started === true, already_running: (d.fetch as { already_running?: unknown }).already_running === true, pid: integerOrNull((d.fetch as { pid?: unknown }).pid) } : null })
512
+ } catch (error) {
513
+ console.warn('[context] embedding bridge failure:', (error as Error).message)
514
+ res.status(503).json({ error: 'graph_unavailable' })
515
+ }
516
+ })
517
+
518
+ /** `{ tier }` → the extraction tier: haiku (Fast), sonnet (Balanced), opus (Deep). */
519
+ memoryRouter.post('/context/graph/setup/extraction', async (req, res) => {
520
+ noStore(res)
521
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
522
+ const tier = typeof (req.body as { tier?: unknown } | undefined)?.tier === 'string' ? (req.body as { tier: string }).tier : ''
523
+ if (!(EXTRACTION_TIERS as readonly string[]).includes(tier)) { res.status(400).json({ error: 'invalid_tier', message: `tier must be one of ${EXTRACTION_TIERS.join(', ')}` }); return }
524
+ try {
525
+ const data = await callPython(['graph-setup-extraction', `--tier=${tier}`], 15_000)
526
+ const code = bridgeErrorCode(data)
527
+ if (code) { sendSetupError(res, code, data); return }
528
+ res.json({ extraction: normalizeExtractionBlock((data as { extraction?: unknown }).extraction) })
529
+ } catch (error) {
530
+ console.warn('[context] extraction bridge failure:', (error as Error).message)
531
+ res.status(503).json({ error: 'graph_unavailable' })
532
+ }
533
+ })
534
+
535
+ function asRecord(value: unknown): Record<string, unknown> | null {
536
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null
537
+ }
538
+ function integerOrNull(value: unknown): number | null {
539
+ return typeof value === 'number' && Number.isInteger(value) ? value : null
540
+ }
541
+
471
542
  /** `{ enabled, interval_s? }` → install or remove the scheduled batch agent on the owner Mac. */
472
543
  memoryRouter.post('/context/graph/setup/schedule', async (req, res) => {
473
544
  noStore(res)