@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 +58 -0
- package/package.json +1 -1
- package/server/lib/cos-context-browser.ts +95 -0
- package/server/lib/python-bridge.ts +6 -0
- package/server/routes/memory.ts +89 -2
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.
|
|
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':
|
package/server/routes/memory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|