@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 +45 -0
- package/package.json +1 -1
- package/server/lib/cos-context-browser.ts +63 -0
- package/server/lib/python-bridge.ts +4 -0
- package/server/routes/memory.ts +73 -2
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.
|
|
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':
|
package/server/routes/memory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|