@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 +50 -0
- package/package.json +1 -1
- package/server/lib/cos-context-browser.ts +137 -0
- package/server/lib/python-bridge.ts +16 -0
- package/server/routes/memory.ts +189 -1
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.
|
|
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':
|
package/server/routes/memory.ts
CHANGED
|
@@ -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
|