@gotcos/glasses-server 6.44.7 → 6.44.10

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,56 @@
1
+ ## 6.44.10
2
+
3
+ Ingest progress for COS Control.
4
+
5
+ - `GET /api/context/graph/ingest/progress` (the bridge's `graph-ingest-progress`,
6
+ read-only): where the current or last Control-started run stands. The run's
7
+ total, how many are indexed and failed, the document in flight with its
8
+ estimated calls, the last eight outcomes with their seconds, the budget line,
9
+ the queue's pending count, and the log's last lines. A run started somewhere
10
+ else (a Claude session) holds the lock but writes no log here, so it reports
11
+ as `external` with the counts only. COS Control 0.5.195's Sync card polls it
12
+ every five seconds while the ingest lock is held.
13
+
14
+ ## 6.44.9
15
+
16
+ Knowledge from zero: the setup path behind COS Control's Knowledge tab.
17
+
18
+ - `GET /api/context/graph/setup` is the readiness checklist (`graph-setup-status`):
19
+ the LightRAG SDK, the model backend, the embedding key, the owner Mac, the
20
+ source folders, the queue and graph counts, today's budget, the scheduled
21
+ agent, and up to three sample documents ready to index.
22
+ - `POST /api/context/graph/setup/sources` `{ action, path }` adds, removes,
23
+ enables or disables a source folder (`graph-setup-sources`); the list comes
24
+ back. `POST /api/context/graph/setup/owner` makes this Mac the ingestion
25
+ owner (`graph-setup-owner`).
26
+ - `POST /api/context/graph/setup/sample` (202) queues up to three documents
27
+ from the enabled sources through the indexer's own dedup and starts one
28
+ bounded run (`graph-ingest-sample`); the reply names what was queued, what
29
+ was skipped and why, and the run's pid or the reason nothing started.
30
+ - `POST /api/context/graph/ask` `{ q }` asks the graph one question
31
+ (`graph-ask`, hybrid mode, 150 s bound) and returns the answer with its
32
+ elapsed time. `POST /api/context/graph/setup/schedule` `{ enabled, interval_s }`
33
+ installs or removes the `com.cos.lightrag-ingest` agent on the owner Mac
34
+ (`graph-schedule`), logging under ~/Library/Logs/COS.
35
+ - A replica answers 409 `not_owner` on every write; a bad field is a 400
36
+ before the bridge is called. Each person's graph, queue, owner file and
37
+ sources stay on their own Mac by construction.
38
+
39
+ ## 6.44.8
40
+
41
+ One more learning write: start indexing the queue.
42
+
43
+ - `POST /api/context/graph/ingest` with `{ "limit": 1..50 }` (default 10) asks
44
+ the bridge's `graph-ingest-start` to run one bounded, detached
45
+ `lightrag_indexer.py --process-queue --limit N` on the ingestion owner and
46
+ answers 202 at once with `{ started, pid, limit, pending, lock }`. Nothing
47
+ started is still a 202 with the reason as a flag: `already_running` (the
48
+ ingest lock is held by a Claude session, a scheduled run or a backup),
49
+ `nothing_pending`, or `budget_exhausted` with `{ used, cap }`. A replica
50
+ answers 409 `not_owner` with the owner host. The child stops on its own: the
51
+ limit, the daily call cap inside the indexer, and the lock it holds for the
52
+ run. COS Control 0.5.192's Sync card uses it for Index now.
53
+
1
54
  ## 6.44.7
2
55
 
3
56
  One learning write: a review decision.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.7",
3
+ "version": "6.44.10",
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": {
@@ -803,6 +803,158 @@ export function normalizeIndexBuildKickoff(value: unknown): { started: boolean;
803
803
  }
804
804
  }
805
805
 
806
+ export const INGEST_LIMIT_DEFAULT = 10
807
+ export const INGEST_LIMIT_MAX = 50
808
+
809
+ export interface IngestKickoff {
810
+ started: boolean
811
+ already_running: boolean
812
+ nothing_pending: boolean
813
+ budget_exhausted: boolean
814
+ pid: number | null
815
+ limit: number | null
816
+ pending: number | null
817
+ lock: { state: string; owner_pid: number | null } | null
818
+ budget: { used: number; cap: number } | null
819
+ }
820
+
821
+ /** The bridge's `graph-ingest-start` answer, every flag a real boolean. */
822
+ export function normalizeIngestKickoff(value: unknown): IngestKickoff {
823
+ const source = asRecord(value) ?? {}
824
+ const lock = asRecord(source.lock)
825
+ const budget = asRecord(source.budget)
826
+ return {
827
+ started: source.started === true,
828
+ already_running: source.already_running === true,
829
+ nothing_pending: source.nothing_pending === true,
830
+ budget_exhausted: source.budget_exhausted === true,
831
+ pid: integerOrAbsent(source.pid) ?? null,
832
+ limit: integerOrAbsent(source.limit) ?? null,
833
+ pending: integerOrAbsent(source.pending) ?? null,
834
+ lock: lock ? { state: stringOrAbsent(lock.state, 16) ?? 'unknown', owner_pid: integerOrAbsent(lock.owner_pid) ?? null } : null,
835
+ budget: budget && integerOrAbsent(budget.used) !== undefined && integerOrAbsent(budget.cap) !== undefined
836
+ ? { used: integerOrAbsent(budget.used)!, cap: integerOrAbsent(budget.cap)! } : null,
837
+ }
838
+ }
839
+
840
+ export const KNOWLEDGE_SETUP_SAMPLE_MAX = 3
841
+
842
+ /**
843
+ * A folder or file path the user chose for the setup path. Unlike
844
+ * `stringOrAbsent`, which hides local paths from surfaces that must not leak
845
+ * them, this keeps the path: COS Control shows it back to the person who
846
+ * picked it, on the Mac it lives on.
847
+ */
848
+ function chosenPathOrAbsent(value: unknown, max = 1000): string | undefined {
849
+ if (typeof value !== 'string') return undefined
850
+ const trimmed = value.trim()
851
+ return trimmed.length > 0 ? trimmed.slice(0, max) : undefined
852
+ }
853
+ export const KNOWLEDGE_ASK_MAX_CHARS = 400
854
+
855
+ export interface KnowledgeSetupCheck { id: string; ok: boolean; detail: string }
856
+ export interface KnowledgeSetupSource { path: string; enabled: boolean; exists: boolean; files: number | null; added_at: string | null }
857
+
858
+ /** `graph-setup-status`: the checklist behind the Knowledge setup path. */
859
+ export function normalizeKnowledgeSetup(value: unknown): Record<string, unknown> {
860
+ const s = asRecord(value) ?? {}
861
+ const owner = asRecord(s.owner) ?? {}
862
+ const queue = asRecord(s.queue) ?? {}
863
+ const graph = asRecord(s.graph) ?? {}
864
+ const budget = asRecord(s.budget) ?? {}
865
+ const schedule = asRecord(s.schedule) ?? {}
866
+ const sample = asRecord(s.sample) ?? {}
867
+ const checks: KnowledgeSetupCheck[] = (Array.isArray(s.checks) ? s.checks : []).flatMap((row) => {
868
+ const r = asRecord(row); if (!r) return []
869
+ const id = stringOrAbsent(r.id, 40); if (!id) return []
870
+ return [{ id, ok: r.ok === true, detail: stringOrAbsent(r.detail, 400) ?? '' }]
871
+ })
872
+ return {
873
+ ready: checks.length > 0 && checks.every(c => c.ok),
874
+ checks,
875
+ owner: {
876
+ owner_host: stringOrAbsent(owner.owner_host, 120) ?? null,
877
+ this_host: stringOrAbsent(owner.this_host, 120) ?? null,
878
+ is_owner: owner.is_owner === true,
879
+ source: stringOrAbsent(owner.source, 16) ?? 'none',
880
+ },
881
+ sources: normalizeKnowledgeSources(s.sources),
882
+ queue: { pending: integerOrAbsent(queue.pending) ?? null, indexed: integerOrAbsent(queue.indexed) ?? null },
883
+ graph: { entities: integerOrAbsent(graph.entities) ?? null, relationships: integerOrAbsent(graph.relationships) ?? null },
884
+ budget: { used: integerOrAbsent(budget.used) ?? null, cap: integerOrAbsent(budget.cap) ?? null },
885
+ schedule: { installed: schedule.installed === true, interval_s: integerOrAbsent(schedule.interval_s) ?? null, plist: chosenPathOrAbsent(schedule.plist, 400) ?? null },
886
+ sample: {
887
+ candidates: (Array.isArray(sample.candidates) ? sample.candidates : []).flatMap((row) => {
888
+ const r = asRecord(row); const path = r ? chosenPathOrAbsent(r.path) : undefined
889
+ return path ? [{ path, bytes: integerOrAbsent(r!.bytes) ?? null }] : []
890
+ }).slice(0, KNOWLEDGE_SETUP_SAMPLE_MAX),
891
+ queued: integerOrAbsent(sample.queued) ?? 0,
892
+ indexed: integerOrAbsent(sample.indexed) ?? 0,
893
+ },
894
+ lock: normalizeIngestKickoff({ lock: s.lock }).lock,
895
+ ask_ready: s.ask_ready === true,
896
+ protocol: integerOrAbsent(s.protocol) ?? null,
897
+ }
898
+ }
899
+
900
+ export function normalizeKnowledgeSources(value: unknown): KnowledgeSetupSource[] {
901
+ return (Array.isArray(value) ? value : []).flatMap((row) => {
902
+ const r = asRecord(row); if (!r) return []
903
+ const path = chosenPathOrAbsent(r.path); if (!path) return []
904
+ return [{ path, enabled: r.enabled !== false, exists: r.exists !== false, files: integerOrAbsent(r.files) ?? null, added_at: isoOrAbsent(r.added_at) ?? null }]
905
+ })
906
+ }
907
+
908
+ /** `graph-ingest-sample`: what was queued, what was not, and whether a run started. */
909
+ export function normalizeSampleKickoff(value: unknown): Record<string, unknown> {
910
+ const s = asRecord(value) ?? {}
911
+ const rows = (key: string, extra: string) => (Array.isArray(s[key]) ? (s[key] as unknown[]) : []).flatMap((row) => {
912
+ const r = asRecord(row); const path = r ? chosenPathOrAbsent(r.path) : undefined
913
+ return path ? [{ path, [extra]: stringOrAbsent(r![extra], 200) ?? null }] : []
914
+ })
915
+ return { ...normalizeIngestKickoff(s), queued: rows('queued', 'id'), skipped: rows('skipped', 'reason') }
916
+ }
917
+
918
+ /** `graph-ask`: one answer from the graph, bounded. */
919
+ export function normalizeGraphAnswer(value: unknown): { question: string; mode: string; answer: string; elapsed_s: number | null } | null {
920
+ const s = asRecord(value) ?? {}
921
+ const answer = typeof s.answer === 'string' ? s.answer.slice(0, 20_000) : null
922
+ if (answer === null) return null
923
+ 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
+ }
925
+
926
+ export interface IngestProgressItem { id: string; outcome: 'indexed' | 'failed' | 'unknown'; seconds: number | null; reason: string | null }
927
+
928
+ /** `graph-ingest-progress`: where the current or last Control-started run stands. */
929
+ export function normalizeIngestProgress(value: unknown): Record<string, unknown> {
930
+ const s = asRecord(value) ?? {}
931
+ const current = asRecord(s.current)
932
+ const budget = asRecord(s.budget)
933
+ const items: IngestProgressItem[] = (Array.isArray(s.items) ? s.items : []).flatMap((row) => {
934
+ const r = asRecord(row); const id = r ? stringOrAbsent(r.id, 200) : undefined
935
+ if (!id) return []
936
+ const outcome: IngestProgressItem['outcome'] = r!.outcome === 'indexed' ? 'indexed' : r!.outcome === 'failed' ? 'failed' : 'unknown'
937
+ return [{ id, outcome, seconds: typeof r!.seconds === 'number' && Number.isFinite(r!.seconds) ? r!.seconds : null, reason: stringOrAbsent(r!.reason, 200) ?? null }]
938
+ }).slice(-8)
939
+ return {
940
+ running: s.running === true,
941
+ pid: integerOrAbsent(s.pid) ?? null,
942
+ external: s.external === true,
943
+ pending: integerOrAbsent(s.pending) ?? null,
944
+ total: integerOrAbsent(s.total) ?? null,
945
+ done: integerOrAbsent(s.done) ?? 0,
946
+ failed: integerOrAbsent(s.failed) ?? 0,
947
+ current: current && stringOrAbsent(current.id, 200) ? { id: stringOrAbsent(current.id, 200)!, est_calls: integerOrAbsent(current.est_calls) ?? null } : null,
948
+ items,
949
+ remaining_calls: integerOrAbsent(s.remaining_calls) ?? null,
950
+ budget: budget && integerOrAbsent(budget.used) !== undefined && integerOrAbsent(budget.cap) !== undefined ? { used: integerOrAbsent(budget.used)!, cap: integerOrAbsent(budget.cap)! } : null,
951
+ ended: s.ended === true,
952
+ 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),
953
+ log_age_s: typeof s.log_age_s === 'number' && Number.isFinite(s.log_age_s) ? s.log_age_s : null,
954
+ lock: normalizeIngestKickoff({ lock: s.lock }).lock,
955
+ }
956
+ }
957
+
806
958
  export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
807
959
  const source = value && typeof value === 'object' && !Array.isArray(value)
808
960
  ? value as Record<string, unknown> : {}
@@ -57,6 +57,14 @@ export const LEARNING_COMMANDS = [
57
57
  'graph-index-build',
58
58
  'learning-to-review',
59
59
  'learning-decide',
60
+ 'graph-ingest-start',
61
+ 'graph-setup-status',
62
+ 'graph-setup-sources',
63
+ 'graph-setup-owner',
64
+ 'graph-ingest-sample',
65
+ 'graph-ask',
66
+ 'graph-schedule',
67
+ 'graph-ingest-progress',
60
68
  ] as const
61
69
 
62
70
  // The optional Python bridge is available only when the user points us at a real
@@ -230,6 +238,14 @@ function standaloneNoop(args: string[]): unknown {
230
238
  case 'graph-index-build':
231
239
  case 'learning-to-review':
232
240
  case 'learning-decide':
241
+ case 'graph-ingest-start':
242
+ case 'graph-setup-status':
243
+ case 'graph-setup-sources':
244
+ case 'graph-setup-owner':
245
+ case 'graph-ingest-sample':
246
+ case 'graph-ask':
247
+ case 'graph-schedule':
248
+ case 'graph-ingest-progress':
233
249
  return { error: 'cos_pipeline_not_configured' }
234
250
  case 'task-rows':
235
251
  case 'task-capture':
@@ -22,6 +22,16 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
22
22
  normalizeGraphSearch,
23
23
  normalizeGraphStatus,
24
24
  normalizeIndexBuildKickoff,
25
+ normalizeIngestKickoff,
26
+ INGEST_LIMIT_DEFAULT,
27
+ INGEST_LIMIT_MAX,
28
+ normalizeKnowledgeSetup,
29
+ normalizeKnowledgeSources,
30
+ normalizeSampleKickoff,
31
+ normalizeGraphAnswer,
32
+ normalizeIngestProgress,
33
+ KNOWLEDGE_SETUP_SAMPLE_MAX,
34
+ KNOWLEDGE_ASK_MAX_CHARS,
25
35
  normalizeLearningEventDetail,
26
36
  normalizeLearningEvents,
27
37
  normalizeLearningStatus,
@@ -303,6 +313,180 @@ memoryRouter.post('/context/graph/index', async (_req, res) => {
303
313
  }
304
314
  })
305
315
 
316
+ /**
317
+ * 202 Accepted: start ONE bounded, detached queue ingest on the ingestion
318
+ * owner (`lightrag_indexer.py --process-queue --limit N`). The bridge command
319
+ * only spawns and answers, so this never holds the ingest lock or waits on a
320
+ * model call. A replica answers 409 `not_owner`; a held lock, an empty queue
321
+ * or a spent daily budget come back as a 202 whose flags say why nothing
322
+ * started. Poll GET /context/graph/status for `lock.state` and `queue.pending`.
323
+ */
324
+ memoryRouter.post('/context/graph/ingest', async (req, res) => {
325
+ noStore(res)
326
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
327
+ const raw = (req.body as { limit?: unknown } | undefined)?.limit
328
+ const limit = raw === undefined || raw === null ? INGEST_LIMIT_DEFAULT : Number(raw)
329
+ if (!Number.isInteger(limit) || limit < 1 || limit > INGEST_LIMIT_MAX) {
330
+ res.status(400).json({ error: 'invalid_limit', message: `limit must be an integer from 1 to ${INGEST_LIMIT_MAX}` })
331
+ return
332
+ }
333
+ try {
334
+ const data = await callPython(['graph-ingest-start', `--limit=${limit}`, '--reason=control'], 5_000)
335
+ const code = bridgeErrorCode(data)
336
+ if (code === 'not_owner') {
337
+ const detail = data as { message?: unknown; owner_host?: unknown }
338
+ 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
+ return
340
+ }
341
+ if (code) { res.status(code.startsWith('invalid_') ? 400 : 503).json({ error: code }); return }
342
+ res.status(202).json(normalizeIngestKickoff(data))
343
+ } catch (error) {
344
+ console.warn('[context] ingest bridge failure:', (error as Error).message)
345
+ res.status(503).json({ error: 'graph_unavailable' })
346
+ }
347
+ })
348
+
349
+ /** Where the current or last Control-started ingest stands (6.44.10). Read-only. */
350
+ memoryRouter.get('/context/graph/ingest/progress', async (_req, res) => {
351
+ noStore(res)
352
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
353
+ try {
354
+ const data = await callPython(['graph-ingest-progress'], 10_000)
355
+ const code = bridgeErrorCode(data)
356
+ if (code) { res.status(503).json({ error: code }); return }
357
+ res.json(normalizeIngestProgress(data))
358
+ } catch (error) {
359
+ console.warn('[context] progress bridge failure:', (error as Error).message)
360
+ res.status(503).json({ error: 'graph_unavailable' })
361
+ }
362
+ })
363
+
364
+ // ── Knowledge setup (6.44.9): from zero to a first index, in COS Control ──
365
+ //
366
+ // Six bridge commands behind one guided path: the readiness checklist, the
367
+ // source folders, the owner Mac, three sample documents, one question, and
368
+ // the scheduled batches. Every write is bounded and owner-only; a replica
369
+ // answers 409 not_owner. Paths and questions ride as single argv tokens.
370
+
371
+ /** Map a bridge error to the status the setup routes share. */
372
+ function sendSetupError(res: import('express').Response, code: string, data: unknown): void {
373
+ const detail = asDetail(data)
374
+ if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
375
+ if (code.endsWith('_not_found')) { res.status(404).json({ error: code, message: detail.message }); return }
376
+ if (code.startsWith('invalid_')) { res.status(400).json({ error: code, message: detail.message }); return }
377
+ res.status(503).json({ error: code })
378
+ }
379
+
380
+ function asDetail(data: unknown): { message?: string; owner_host?: string } {
381
+ const d = (typeof data === 'object' && data !== null ? data : {}) as { message?: unknown; owner_host?: unknown }
382
+ return { message: typeof d.message === 'string' ? d.message : undefined, owner_host: typeof d.owner_host === 'string' ? d.owner_host : undefined }
383
+ }
384
+
385
+ memoryRouter.get('/context/graph/setup', async (_req, res) => {
386
+ noStore(res)
387
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
388
+ try {
389
+ const data = await callPython(['graph-setup-status'], 20_000)
390
+ const code = bridgeErrorCode(data)
391
+ if (code) { sendSetupError(res, code, data); return }
392
+ res.json(normalizeKnowledgeSetup(data))
393
+ } catch (error) {
394
+ console.warn('[context] setup bridge failure:', (error as Error).message)
395
+ res.status(503).json({ error: 'graph_unavailable' })
396
+ }
397
+ })
398
+
399
+ /** `{ action: add | remove | enable | disable, path }` → the source list after the change. */
400
+ memoryRouter.post('/context/graph/setup/sources', async (req, res) => {
401
+ noStore(res)
402
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
403
+ const body = (req.body ?? {}) as { action?: unknown; path?: unknown }
404
+ const action = typeof body.action === 'string' ? body.action : ''
405
+ const path = typeof body.path === 'string' ? body.path.trim() : ''
406
+ if (!['add', 'remove', 'enable', 'disable'].includes(action)) { res.status(400).json({ error: 'invalid_action', message: 'action must be add, remove, enable or disable' }); return }
407
+ if (!path || path.length > 1000 || path.includes('\0')) { res.status(400).json({ error: 'invalid_path', message: 'path must be 1 to 1000 characters' }); return }
408
+ try {
409
+ const data = await callPython(['graph-setup-sources', `--action=${action}`, `--path=${path}`], 15_000)
410
+ const code = bridgeErrorCode(data)
411
+ if (code) { sendSetupError(res, code, data); return }
412
+ const source = data as { sources?: unknown }
413
+ res.json({ sources: normalizeKnowledgeSources(source.sources) })
414
+ } catch (error) {
415
+ console.warn('[context] setup bridge failure:', (error as Error).message)
416
+ res.status(503).json({ error: 'graph_unavailable' })
417
+ }
418
+ })
419
+
420
+ memoryRouter.post('/context/graph/setup/owner', async (_req, res) => {
421
+ noStore(res)
422
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
423
+ try {
424
+ const data = await callPython(['graph-setup-owner', '--this-mac'], 10_000)
425
+ const code = bridgeErrorCode(data)
426
+ if (code) { sendSetupError(res, code, data); return }
427
+ res.json(normalizeKnowledgeSetup({ owner: (data as { owner?: unknown }).owner }).owner === undefined ? {} : { owner: normalizeKnowledgeSetup(data).owner })
428
+ } catch (error) {
429
+ console.warn('[context] setup bridge failure:', (error as Error).message)
430
+ res.status(503).json({ error: 'graph_unavailable' })
431
+ }
432
+ })
433
+
434
+ /** 202: queue up to three sample documents from the enabled sources and start one bounded run. */
435
+ memoryRouter.post('/context/graph/setup/sample', async (req, res) => {
436
+ noStore(res)
437
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
438
+ const raw = (req.body as { limit?: unknown } | undefined)?.limit
439
+ const limit = raw === undefined || raw === null ? KNOWLEDGE_SETUP_SAMPLE_MAX : Number(raw)
440
+ if (!Number.isInteger(limit) || limit < 1 || limit > KNOWLEDGE_SETUP_SAMPLE_MAX) { res.status(400).json({ error: 'invalid_limit', message: `limit must be an integer from 1 to ${KNOWLEDGE_SETUP_SAMPLE_MAX}` }); return }
441
+ try {
442
+ const data = await callPython(['graph-ingest-sample', `--limit=${limit}`, '--reason=control'], 90_000)
443
+ const code = bridgeErrorCode(data)
444
+ if (code) { sendSetupError(res, code, data); return }
445
+ res.status(202).json(normalizeSampleKickoff(data))
446
+ } catch (error) {
447
+ console.warn('[context] sample bridge failure:', (error as Error).message)
448
+ res.status(503).json({ error: 'graph_unavailable' })
449
+ }
450
+ })
451
+
452
+ /** One question to the graph. Two model calls under the query budget; bounded to 150 s. */
453
+ memoryRouter.post('/context/graph/ask', async (req, res) => {
454
+ noStore(res)
455
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
456
+ const q = typeof (req.body as { q?: unknown } | undefined)?.q === 'string' ? ((req.body as { q: string }).q).trim() : ''
457
+ if (q.length < 3 || q.length > KNOWLEDGE_ASK_MAX_CHARS) { res.status(400).json({ error: 'invalid_query', message: `q must be 3 to ${KNOWLEDGE_ASK_MAX_CHARS} characters` }); return }
458
+ try {
459
+ const data = await callPython(['graph-ask', `--q=${q}`], 150_000)
460
+ const code = bridgeErrorCode(data)
461
+ if (code) { sendSetupError(res, code, data); return }
462
+ const answer = normalizeGraphAnswer(data)
463
+ if (!answer) { res.status(503).json({ error: 'graph_no_answer' }); return }
464
+ res.json(answer)
465
+ } catch (error) {
466
+ console.warn('[context] ask bridge failure:', (error as Error).message)
467
+ res.status(503).json({ error: 'graph_unavailable' })
468
+ }
469
+ })
470
+
471
+ /** `{ enabled, interval_s? }` → install or remove the scheduled batch agent on the owner Mac. */
472
+ memoryRouter.post('/context/graph/setup/schedule', async (req, res) => {
473
+ noStore(res)
474
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
475
+ const body = (req.body ?? {}) as { enabled?: unknown; interval_s?: unknown }
476
+ if (typeof body.enabled !== 'boolean') { res.status(400).json({ error: 'invalid_enabled', message: 'enabled must be true or false' }); return }
477
+ const interval = body.interval_s === undefined || body.interval_s === null ? 3600 : Number(body.interval_s)
478
+ if (!Number.isInteger(interval) || interval < 900 || interval > 86_400) { res.status(400).json({ error: 'invalid_interval', message: 'interval_s must be an integer from 900 to 86400' }); return }
479
+ try {
480
+ const data = await callPython(['graph-schedule', `--enabled=${body.enabled}`, `--interval-s=${interval}`], 20_000)
481
+ const code = bridgeErrorCode(data)
482
+ if (code) { sendSetupError(res, code, data); return }
483
+ res.json(normalizeKnowledgeSetup({ schedule: (data as { schedule?: unknown }).schedule ?? data }).schedule)
484
+ } catch (error) {
485
+ console.warn('[context] schedule bridge failure:', (error as Error).message)
486
+ res.status(503).json({ error: 'graph_unavailable' })
487
+ }
488
+ })
489
+
306
490
  function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
307
491
  const parsed = Number(value)
308
492
  if (!Number.isFinite(parsed)) return fallback