@gotcos/glasses-server 6.44.6 → 6.44.9

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,53 @@
1
+ ## 6.44.9
2
+
3
+ Knowledge from zero: the setup path behind COS Control's Knowledge tab.
4
+
5
+ - `GET /api/context/graph/setup` is the readiness checklist (`graph-setup-status`):
6
+ the LightRAG SDK, the model backend, the embedding key, the owner Mac, the
7
+ source folders, the queue and graph counts, today's budget, the scheduled
8
+ agent, and up to three sample documents ready to index.
9
+ - `POST /api/context/graph/setup/sources` `{ action, path }` adds, removes,
10
+ enables or disables a source folder (`graph-setup-sources`); the list comes
11
+ back. `POST /api/context/graph/setup/owner` makes this Mac the ingestion
12
+ owner (`graph-setup-owner`).
13
+ - `POST /api/context/graph/setup/sample` (202) queues up to three documents
14
+ from the enabled sources through the indexer's own dedup and starts one
15
+ bounded run (`graph-ingest-sample`); the reply names what was queued, what
16
+ was skipped and why, and the run's pid or the reason nothing started.
17
+ - `POST /api/context/graph/ask` `{ q }` asks the graph one question
18
+ (`graph-ask`, hybrid mode, 150 s bound) and returns the answer with its
19
+ elapsed time. `POST /api/context/graph/setup/schedule` `{ enabled, interval_s }`
20
+ installs or removes the `com.cos.lightrag-ingest` agent on the owner Mac
21
+ (`graph-schedule`), logging under ~/Library/Logs/COS.
22
+ - A replica answers 409 `not_owner` on every write; a bad field is a 400
23
+ before the bridge is called. Each person's graph, queue, owner file and
24
+ sources stay on their own Mac by construction.
25
+
26
+ ## 6.44.8
27
+
28
+ One more learning write: start indexing the queue.
29
+
30
+ - `POST /api/context/graph/ingest` with `{ "limit": 1..50 }` (default 10) asks
31
+ the bridge's `graph-ingest-start` to run one bounded, detached
32
+ `lightrag_indexer.py --process-queue --limit N` on the ingestion owner and
33
+ answers 202 at once with `{ started, pid, limit, pending, lock }`. Nothing
34
+ started is still a 202 with the reason as a flag: `already_running` (the
35
+ ingest lock is held by a Claude session, a scheduled run or a backup),
36
+ `nothing_pending`, or `budget_exhausted` with `{ used, cap }`. A replica
37
+ answers 409 `not_owner` with the owner host. The child stops on its own: the
38
+ limit, the daily call cap inside the indexer, and the lock it holds for the
39
+ run. COS Control 0.5.192's Sync card uses it for Index now.
40
+
41
+ ## 6.44.7
42
+
43
+ One learning write: a review decision.
44
+
45
+ - `POST /api/context/learning/:id/review` with `{ "decision": "dismissed" | "reopened" }`
46
+ appends a review-ledger row through the bridge's `learning-decide`. A
47
+ dismissed proposal leaves the To review set on the next read; reopened puts
48
+ it back. Nothing else is written: no skill, no memory, no graph. COS Control
49
+ 0.5.191's Memories tab uses it for Dismiss and Restore proposal.
50
+
1
51
  ## 6.44.6
2
52
 
3
53
  What /qa found in 6.44.5 before anyone installed it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.6",
3
+ "version": "6.44.9",
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": {
@@ -776,6 +776,23 @@ export function normalizeGraphPassages(value: unknown): Record<string, unknown>
776
776
  }
777
777
  }
778
778
 
779
+ /** The review decision the bridge wrote back: lesson, decision, stamp, id, who. */
780
+ export function normalizeReviewDecision(value: unknown): Record<string, unknown> | null {
781
+ const source = asRecord(value)
782
+ const row = asRecord(source?.decision)
783
+ if (!row) return null
784
+ const decision = stringOrAbsent(row.decision, 16)
785
+ const lessonId = stringOrAbsent(row.lesson_id, 200)
786
+ if (!lessonId || (decision !== 'dismissed' && decision !== 'reopened')) return null
787
+ return {
788
+ lesson_id: lessonId,
789
+ decision,
790
+ ts: isoOrAbsent(row.ts) ?? null,
791
+ event_id: LEARNING_EVENT_ID_PATTERN.test(String(row.event_id ?? '')) ? String(row.event_id) : null,
792
+ by: stringOrAbsent(row.by, 32) ?? null,
793
+ }
794
+ }
795
+
779
796
  export function normalizeIndexBuildKickoff(value: unknown): { started: boolean; already_running: boolean; pid: number | null; receipt: Record<string, unknown> | null } {
780
797
  const source = asRecord(value) ?? {}
781
798
  return {
@@ -786,6 +803,126 @@ export function normalizeIndexBuildKickoff(value: unknown): { started: boolean;
786
803
  }
787
804
  }
788
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
+
789
926
  export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
790
927
  const source = value && typeof value === 'object' && !Array.isArray(value)
791
928
  ? value as Record<string, unknown> : {}
@@ -56,6 +56,14 @@ export const LEARNING_COMMANDS = [
56
56
  'graph-passages',
57
57
  'graph-index-build',
58
58
  'learning-to-review',
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',
59
67
  ] as const
60
68
 
61
69
  // The optional Python bridge is available only when the user points us at a real
@@ -228,6 +236,14 @@ function standaloneNoop(args: string[]): unknown {
228
236
  case 'graph-passages':
229
237
  case 'graph-index-build':
230
238
  case 'learning-to-review':
239
+ case 'learning-decide':
240
+ case 'graph-ingest-start':
241
+ case 'graph-setup-status':
242
+ case 'graph-setup-sources':
243
+ case 'graph-setup-owner':
244
+ case 'graph-ingest-sample':
245
+ case 'graph-ask':
246
+ case 'graph-schedule':
231
247
  return { error: 'cos_pipeline_not_configured' }
232
248
  case 'task-rows':
233
249
  case 'task-capture':
@@ -13,7 +13,7 @@ import { searchMemories } from '../lib/context-library-search.js'
13
13
  function contextConfigured(): boolean {
14
14
  return contextSourceAvailable() !== null
15
15
  }
16
- import { LEARNING_REVIEW_LIMIT,
16
+ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
17
17
  GRAPH_ENTITY_ID_LIMIT,
18
18
  LEARNING_EVENT_ID_PATTERN,
19
19
  MEMORY_ID_PATTERN,
@@ -22,6 +22,15 @@ import { 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
+ KNOWLEDGE_SETUP_SAMPLE_MAX,
33
+ KNOWLEDGE_ASK_MAX_CHARS,
25
34
  normalizeLearningEventDetail,
26
35
  normalizeLearningEvents,
27
36
  normalizeLearningStatus,
@@ -167,6 +176,26 @@ memoryRouter.get('/context/learning/review', async (req, res) => {
167
176
  }
168
177
  })
169
178
 
179
+ memoryRouter.post('/context/learning/:id/review', async (req, res) => {
180
+ noStore(res)
181
+ // The one learning write (6.44.7): a review decision on a lesson, appended to
182
+ // the review ledger by the bridge. Dismissed leaves To review, reopened
183
+ // returns; nothing else is touched. The lesson id is a store id, not an event id.
184
+ const lessonId = String(req.params.id)
185
+ const decision = typeof req.body?.decision === 'string' ? req.body.decision : ''
186
+ if (!lessonId || lessonId.length > 200 || CONTROL_CHARACTER.test(lessonId)) { res.status(400).json({ error: 'invalid_lesson_id' }); return }
187
+ if (decision !== 'dismissed' && decision !== 'reopened') { res.status(400).json({ error: 'invalid_decision' }); return }
188
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
189
+ try {
190
+ const note = typeof req.body?.note === 'string' ? req.body.note.slice(0, 400) : ''
191
+ const answer = await callPython(['learning-decide', `--id=${lessonId}`, `--decision=${decision}`, ...(note ? [`--note=${note}`] : [])], 8_000)
192
+ sendBridgeAnswer(res, answer, value => normalizeReviewDecision(value))
193
+ } catch (error) {
194
+ console.warn('[context] learning decide bridge failure:', (error as Error).message)
195
+ res.status(503).json({ error: 'learning_unavailable' })
196
+ }
197
+ })
198
+
170
199
  memoryRouter.get('/context/learning/:id', async (req, res) => {
171
200
  noStore(res)
172
201
  if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
@@ -283,6 +312,165 @@ memoryRouter.post('/context/graph/index', async (_req, res) => {
283
312
  }
284
313
  })
285
314
 
315
+ /**
316
+ * 202 Accepted: start ONE bounded, detached queue ingest on the ingestion
317
+ * owner (`lightrag_indexer.py --process-queue --limit N`). The bridge command
318
+ * only spawns and answers, so this never holds the ingest lock or waits on a
319
+ * model call. A replica answers 409 `not_owner`; a held lock, an empty queue
320
+ * or a spent daily budget come back as a 202 whose flags say why nothing
321
+ * started. Poll GET /context/graph/status for `lock.state` and `queue.pending`.
322
+ */
323
+ memoryRouter.post('/context/graph/ingest', async (req, res) => {
324
+ noStore(res)
325
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
326
+ const raw = (req.body as { limit?: unknown } | undefined)?.limit
327
+ const limit = raw === undefined || raw === null ? INGEST_LIMIT_DEFAULT : Number(raw)
328
+ if (!Number.isInteger(limit) || limit < 1 || limit > INGEST_LIMIT_MAX) {
329
+ res.status(400).json({ error: 'invalid_limit', message: `limit must be an integer from 1 to ${INGEST_LIMIT_MAX}` })
330
+ return
331
+ }
332
+ try {
333
+ const data = await callPython(['graph-ingest-start', `--limit=${limit}`, '--reason=control'], 5_000)
334
+ const code = bridgeErrorCode(data)
335
+ if (code === 'not_owner') {
336
+ const detail = data as { message?: unknown; owner_host?: unknown }
337
+ 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
+ return
339
+ }
340
+ if (code) { res.status(code.startsWith('invalid_') ? 400 : 503).json({ error: code }); return }
341
+ res.status(202).json(normalizeIngestKickoff(data))
342
+ } catch (error) {
343
+ console.warn('[context] ingest bridge failure:', (error as Error).message)
344
+ res.status(503).json({ error: 'graph_unavailable' })
345
+ }
346
+ })
347
+
348
+ // ── Knowledge setup (6.44.9): from zero to a first index, in COS Control ──
349
+ //
350
+ // Six bridge commands behind one guided path: the readiness checklist, the
351
+ // source folders, the owner Mac, three sample documents, one question, and
352
+ // the scheduled batches. Every write is bounded and owner-only; a replica
353
+ // answers 409 not_owner. Paths and questions ride as single argv tokens.
354
+
355
+ /** Map a bridge error to the status the setup routes share. */
356
+ function sendSetupError(res: import('express').Response, code: string, data: unknown): void {
357
+ const detail = asDetail(data)
358
+ if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
359
+ if (code.endsWith('_not_found')) { res.status(404).json({ error: code, message: detail.message }); return }
360
+ if (code.startsWith('invalid_')) { res.status(400).json({ error: code, message: detail.message }); return }
361
+ res.status(503).json({ error: code })
362
+ }
363
+
364
+ function asDetail(data: unknown): { message?: string; owner_host?: string } {
365
+ const d = (typeof data === 'object' && data !== null ? data : {}) as { message?: unknown; owner_host?: unknown }
366
+ return { message: typeof d.message === 'string' ? d.message : undefined, owner_host: typeof d.owner_host === 'string' ? d.owner_host : undefined }
367
+ }
368
+
369
+ memoryRouter.get('/context/graph/setup', async (_req, res) => {
370
+ noStore(res)
371
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
372
+ try {
373
+ const data = await callPython(['graph-setup-status'], 20_000)
374
+ const code = bridgeErrorCode(data)
375
+ if (code) { sendSetupError(res, code, data); return }
376
+ res.json(normalizeKnowledgeSetup(data))
377
+ } catch (error) {
378
+ console.warn('[context] setup bridge failure:', (error as Error).message)
379
+ res.status(503).json({ error: 'graph_unavailable' })
380
+ }
381
+ })
382
+
383
+ /** `{ action: add | remove | enable | disable, path }` → the source list after the change. */
384
+ memoryRouter.post('/context/graph/setup/sources', async (req, res) => {
385
+ noStore(res)
386
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
387
+ const body = (req.body ?? {}) as { action?: unknown; path?: unknown }
388
+ const action = typeof body.action === 'string' ? body.action : ''
389
+ const path = typeof body.path === 'string' ? body.path.trim() : ''
390
+ if (!['add', 'remove', 'enable', 'disable'].includes(action)) { res.status(400).json({ error: 'invalid_action', message: 'action must be add, remove, enable or disable' }); return }
391
+ if (!path || path.length > 1000 || path.includes('\0')) { res.status(400).json({ error: 'invalid_path', message: 'path must be 1 to 1000 characters' }); return }
392
+ try {
393
+ const data = await callPython(['graph-setup-sources', `--action=${action}`, `--path=${path}`], 15_000)
394
+ const code = bridgeErrorCode(data)
395
+ if (code) { sendSetupError(res, code, data); return }
396
+ const source = data as { sources?: unknown }
397
+ res.json({ sources: normalizeKnowledgeSources(source.sources) })
398
+ } catch (error) {
399
+ console.warn('[context] setup bridge failure:', (error as Error).message)
400
+ res.status(503).json({ error: 'graph_unavailable' })
401
+ }
402
+ })
403
+
404
+ memoryRouter.post('/context/graph/setup/owner', async (_req, res) => {
405
+ noStore(res)
406
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
407
+ try {
408
+ const data = await callPython(['graph-setup-owner', '--this-mac'], 10_000)
409
+ const code = bridgeErrorCode(data)
410
+ if (code) { sendSetupError(res, code, data); return }
411
+ res.json(normalizeKnowledgeSetup({ owner: (data as { owner?: unknown }).owner }).owner === undefined ? {} : { owner: normalizeKnowledgeSetup(data).owner })
412
+ } catch (error) {
413
+ console.warn('[context] setup bridge failure:', (error as Error).message)
414
+ res.status(503).json({ error: 'graph_unavailable' })
415
+ }
416
+ })
417
+
418
+ /** 202: queue up to three sample documents from the enabled sources and start one bounded run. */
419
+ memoryRouter.post('/context/graph/setup/sample', async (req, res) => {
420
+ noStore(res)
421
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
422
+ const raw = (req.body as { limit?: unknown } | undefined)?.limit
423
+ const limit = raw === undefined || raw === null ? KNOWLEDGE_SETUP_SAMPLE_MAX : Number(raw)
424
+ 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 }
425
+ try {
426
+ const data = await callPython(['graph-ingest-sample', `--limit=${limit}`, '--reason=control'], 90_000)
427
+ const code = bridgeErrorCode(data)
428
+ if (code) { sendSetupError(res, code, data); return }
429
+ res.status(202).json(normalizeSampleKickoff(data))
430
+ } catch (error) {
431
+ console.warn('[context] sample bridge failure:', (error as Error).message)
432
+ res.status(503).json({ error: 'graph_unavailable' })
433
+ }
434
+ })
435
+
436
+ /** One question to the graph. Two model calls under the query budget; bounded to 150 s. */
437
+ memoryRouter.post('/context/graph/ask', async (req, res) => {
438
+ noStore(res)
439
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
440
+ const q = typeof (req.body as { q?: unknown } | undefined)?.q === 'string' ? ((req.body as { q: string }).q).trim() : ''
441
+ 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 }
442
+ try {
443
+ const data = await callPython(['graph-ask', `--q=${q}`], 150_000)
444
+ const code = bridgeErrorCode(data)
445
+ if (code) { sendSetupError(res, code, data); return }
446
+ const answer = normalizeGraphAnswer(data)
447
+ if (!answer) { res.status(503).json({ error: 'graph_no_answer' }); return }
448
+ res.json(answer)
449
+ } catch (error) {
450
+ console.warn('[context] ask bridge failure:', (error as Error).message)
451
+ res.status(503).json({ error: 'graph_unavailable' })
452
+ }
453
+ })
454
+
455
+ /** `{ enabled, interval_s? }` → install or remove the scheduled batch agent on the owner Mac. */
456
+ memoryRouter.post('/context/graph/setup/schedule', async (req, res) => {
457
+ noStore(res)
458
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
459
+ const body = (req.body ?? {}) as { enabled?: unknown; interval_s?: unknown }
460
+ if (typeof body.enabled !== 'boolean') { res.status(400).json({ error: 'invalid_enabled', message: 'enabled must be true or false' }); return }
461
+ const interval = body.interval_s === undefined || body.interval_s === null ? 3600 : Number(body.interval_s)
462
+ 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 }
463
+ try {
464
+ const data = await callPython(['graph-schedule', `--enabled=${body.enabled}`, `--interval-s=${interval}`], 20_000)
465
+ const code = bridgeErrorCode(data)
466
+ if (code) { sendSetupError(res, code, data); return }
467
+ res.json(normalizeKnowledgeSetup({ schedule: (data as { schedule?: unknown }).schedule ?? data }).schedule)
468
+ } catch (error) {
469
+ console.warn('[context] schedule bridge failure:', (error as Error).message)
470
+ res.status(503).json({ error: 'graph_unavailable' })
471
+ }
472
+ })
473
+
286
474
  function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
287
475
  const parsed = Number(value)
288
476
  if (!Number.isFinite(parsed)) return fallback