@gotcos/glasses-server 6.44.9 → 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,61 @@
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
+
46
+ ## 6.44.10
47
+
48
+ Ingest progress for COS Control.
49
+
50
+ - `GET /api/context/graph/ingest/progress` (the bridge's `graph-ingest-progress`,
51
+ read-only): where the current or last Control-started run stands. The run's
52
+ total, how many are indexed and failed, the document in flight with its
53
+ estimated calls, the last eight outcomes with their seconds, the budget line,
54
+ the queue's pending count, and the log's last lines. A run started somewhere
55
+ else (a Claude session) holds the lock but writes no log here, so it reports
56
+ as `external` with the counts only. COS Control 0.5.195's Sync card polls it
57
+ every five seconds while the ingest lock is held.
58
+
1
59
  ## 6.44.9
2
60
 
3
61
  Knowledge from zero: the setup path behind COS Control's Knowledge tab.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.9",
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
  }
@@ -923,6 +986,38 @@ export function normalizeGraphAnswer(value: unknown): { question: string; mode:
923
986
  return { question: stringOrAbsent(s.question, KNOWLEDGE_ASK_MAX_CHARS) ?? '', mode: stringOrAbsent(s.mode, 16) ?? 'hybrid', answer, elapsed_s: typeof s.elapsed_s === 'number' && Number.isFinite(s.elapsed_s) ? s.elapsed_s : null }
924
987
  }
925
988
 
989
+ export interface IngestProgressItem { id: string; outcome: 'indexed' | 'failed' | 'unknown'; seconds: number | null; reason: string | null }
990
+
991
+ /** `graph-ingest-progress`: where the current or last Control-started run stands. */
992
+ export function normalizeIngestProgress(value: unknown): Record<string, unknown> {
993
+ const s = asRecord(value) ?? {}
994
+ const current = asRecord(s.current)
995
+ const budget = asRecord(s.budget)
996
+ const items: IngestProgressItem[] = (Array.isArray(s.items) ? s.items : []).flatMap((row) => {
997
+ const r = asRecord(row); const id = r ? stringOrAbsent(r.id, 200) : undefined
998
+ if (!id) return []
999
+ const outcome: IngestProgressItem['outcome'] = r!.outcome === 'indexed' ? 'indexed' : r!.outcome === 'failed' ? 'failed' : 'unknown'
1000
+ return [{ id, outcome, seconds: typeof r!.seconds === 'number' && Number.isFinite(r!.seconds) ? r!.seconds : null, reason: stringOrAbsent(r!.reason, 200) ?? null }]
1001
+ }).slice(-8)
1002
+ return {
1003
+ running: s.running === true,
1004
+ pid: integerOrAbsent(s.pid) ?? null,
1005
+ external: s.external === true,
1006
+ pending: integerOrAbsent(s.pending) ?? null,
1007
+ total: integerOrAbsent(s.total) ?? null,
1008
+ done: integerOrAbsent(s.done) ?? 0,
1009
+ failed: integerOrAbsent(s.failed) ?? 0,
1010
+ current: current && stringOrAbsent(current.id, 200) ? { id: stringOrAbsent(current.id, 200)!, est_calls: integerOrAbsent(current.est_calls) ?? null } : null,
1011
+ items,
1012
+ remaining_calls: integerOrAbsent(s.remaining_calls) ?? null,
1013
+ budget: budget && integerOrAbsent(budget.used) !== undefined && integerOrAbsent(budget.cap) !== undefined ? { used: integerOrAbsent(budget.used)!, cap: integerOrAbsent(budget.cap)! } : null,
1014
+ ended: s.ended === true,
1015
+ 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),
1016
+ log_age_s: typeof s.log_age_s === 'number' && Number.isFinite(s.log_age_s) ? s.log_age_s : null,
1017
+ lock: normalizeIngestKickoff({ lock: s.lock }).lock,
1018
+ }
1019
+ }
1020
+
926
1021
  export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
927
1022
  const source = value && typeof value === 'object' && !Array.isArray(value)
928
1023
  ? value as Record<string, unknown> : {}
@@ -64,6 +64,9 @@ export const LEARNING_COMMANDS = [
64
64
  'graph-ingest-sample',
65
65
  'graph-ask',
66
66
  'graph-schedule',
67
+ 'graph-ingest-progress',
68
+ 'graph-setup-embedding',
69
+ 'graph-setup-extraction',
67
70
  ] as const
68
71
 
69
72
  // The optional Python bridge is available only when the user points us at a real
@@ -244,6 +247,9 @@ function standaloneNoop(args: string[]): unknown {
244
247
  case 'graph-ingest-sample':
245
248
  case 'graph-ask':
246
249
  case 'graph-schedule':
250
+ case 'graph-ingest-progress':
251
+ case 'graph-setup-embedding':
252
+ case 'graph-setup-extraction':
247
253
  return { error: 'cos_pipeline_not_configured' }
248
254
  case 'task-rows':
249
255
  case 'task-capture':
@@ -29,6 +29,11 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
29
29
  normalizeKnowledgeSources,
30
30
  normalizeSampleKickoff,
31
31
  normalizeGraphAnswer,
32
+ normalizeIngestProgress,
33
+ normalizeEmbeddingBlock,
34
+ normalizeExtractionBlock,
35
+ EMBEDDING_PROVIDERS,
36
+ EXTRACTION_TIERS,
32
37
  KNOWLEDGE_SETUP_SAMPLE_MAX,
33
38
  KNOWLEDGE_ASK_MAX_CHARS,
34
39
  normalizeLearningEventDetail,
@@ -330,14 +335,16 @@ memoryRouter.post('/context/graph/ingest', async (req, res) => {
330
335
  return
331
336
  }
332
337
  try {
333
- 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)
334
341
  const code = bridgeErrorCode(data)
335
342
  if (code === 'not_owner') {
336
343
  const detail = data as { message?: unknown; owner_host?: unknown }
337
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 })
338
345
  return
339
346
  }
340
- if (code) { res.status(code.startsWith('invalid_') ? 400 : 503).json({ error: code }); return }
347
+ if (code) { sendSetupError(res, code, data); return }
341
348
  res.status(202).json(normalizeIngestKickoff(data))
342
349
  } catch (error) {
343
350
  console.warn('[context] ingest bridge failure:', (error as Error).message)
@@ -345,6 +352,21 @@ memoryRouter.post('/context/graph/ingest', async (req, res) => {
345
352
  }
346
353
  })
347
354
 
355
+ /** Where the current or last Control-started ingest stands (6.44.10). Read-only. */
356
+ memoryRouter.get('/context/graph/ingest/progress', async (_req, res) => {
357
+ noStore(res)
358
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
359
+ try {
360
+ const data = await callPython(['graph-ingest-progress'], 10_000)
361
+ const code = bridgeErrorCode(data)
362
+ if (code) { res.status(503).json({ error: code }); return }
363
+ res.json(normalizeIngestProgress(data))
364
+ } catch (error) {
365
+ console.warn('[context] progress bridge failure:', (error as Error).message)
366
+ res.status(503).json({ error: 'graph_unavailable' })
367
+ }
368
+ })
369
+
348
370
  // ── Knowledge setup (6.44.9): from zero to a first index, in COS Control ──
349
371
  //
350
372
  // Six bridge commands behind one guided path: the readiness checklist, the
@@ -356,6 +378,14 @@ memoryRouter.post('/context/graph/ingest', async (req, res) => {
356
378
  function sendSetupError(res: import('express').Response, code: string, data: unknown): void {
357
379
  const detail = asDetail(data)
358
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
+ }
359
389
  if (code.endsWith('_not_found')) { res.status(404).json({ error: code, message: detail.message }); return }
360
390
  if (code.startsWith('invalid_')) { res.status(400).json({ error: code, message: detail.message }); return }
361
391
  res.status(503).json({ error: code })
@@ -452,6 +482,63 @@ memoryRouter.post('/context/graph/ask', async (req, res) => {
452
482
  }
453
483
  })
454
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
+
455
542
  /** `{ enabled, interval_s? }` → install or remove the scheduled batch agent on the owner Mac. */
456
543
  memoryRouter.post('/context/graph/setup/schedule', async (req, res) => {
457
544
  noStore(res)