@gotcos/glasses-server 6.44.10 → 6.44.13
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 +68 -0
- package/package.json +1 -1
- package/server/lib/cos-context-browser.ts +107 -2
- package/server/lib/python-bridge.ts +10 -0
- package/server/routes/memory.ts +161 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
+
## 6.44.13
|
|
2
|
+
|
|
3
|
+
Prune or accept a captured memory, and guardrails that prune nonsense for you.
|
|
4
|
+
|
|
5
|
+
- Miles 2026-09-07, looking at a captured decision that read "AAAA...":
|
|
6
|
+
"we need the ability to either prune or accept the memory", and "an
|
|
7
|
+
automated review ... another person could set their own guardrails that
|
|
8
|
+
automatically just prune out stuff like this."
|
|
9
|
+
- `POST /api/context/memory/:id/review` `{ decision: accept | prune, note? }`
|
|
10
|
+
stamps a captured memory as reviewed or deletes it; either way a review
|
|
11
|
+
ledger row (`accepted`, `pruned`) the learning timeline shows. Both are new
|
|
12
|
+
event types.
|
|
13
|
+
- `GET` and `PUT /api/context/memory-guardrails`: the user's own rules
|
|
14
|
+
(minimum words, distinct characters, a repeat-ratio ceiling, banned
|
|
15
|
+
patterns, and an optional model pass: on or off, the tier, at most N per
|
|
16
|
+
run). The same rules refuse nonsense at capture time in the bridge's COS.
|
|
17
|
+
- `POST /api/context/memory-guardrails/run` `{ days?, apply?, llm? }` scans
|
|
18
|
+
the captured memories, judges them (rules first; the model pass against
|
|
19
|
+
the COS philosophy principles only on what survives, only when enabled),
|
|
20
|
+
and returns every verdict with its reasons. Nothing is deleted without
|
|
21
|
+
`apply`; a record a person accepted is never flagged.
|
|
22
|
+
- Includes 6.44.12, never published.
|
|
23
|
+
|
|
24
|
+
## 6.44.12
|
|
25
|
+
|
|
26
|
+
The Knowledge down-select, and a fail-closed embedding gate.
|
|
27
|
+
|
|
28
|
+
- Miles 2026-09-07: the local embeddings are the free path for people who
|
|
29
|
+
run COS without an OpenAI key, and the setup's job is to help each person
|
|
30
|
+
down-select. The `embedding` block now carries `preference.local_only`
|
|
31
|
+
(the one question the user answers: may text leave this Mac?),
|
|
32
|
+
`recommended` (which provider to mark and the one-sentence reason: a key
|
|
33
|
+
plus the cloud allowed recommends OpenAI large; stay local recommends
|
|
34
|
+
Local premium when Ollama is running on the Mac, else Local light), and
|
|
35
|
+
`ready`/`fix` for the chosen provider. Every provider row carries
|
|
36
|
+
`present` (the key, Ollama, or the engine is there) and `fix` (the
|
|
37
|
+
sentence that makes it ready) beside `ready`.
|
|
38
|
+
- `POST /api/context/graph/setup/embedding` accepts `local_only` (boolean),
|
|
39
|
+
alone or with `provider`; alone it moves the Recommended mark and changes
|
|
40
|
+
no choice. One of `provider` or `local_only` is required.
|
|
41
|
+
- Every indexing kickoff (`POST /api/context/graph/ingest`, `/setup/sample`)
|
|
42
|
+
answers 409 `embedding_not_ready` `{ provider, fix, message }` when the
|
|
43
|
+
chosen embedding cannot embed on this Mac right now (no key, Ollama not
|
|
44
|
+
running or the model not pulled, fastembed absent), instead of spawning an
|
|
45
|
+
indexer that dies in its log. A status block without `ready` counts as
|
|
46
|
+
not ready.
|
|
47
|
+
- The ingest kickoff's bridge budget is 20 s, not 5: the readiness gate probes
|
|
48
|
+
Ollama before it spawns, and the spawn still returns at once.
|
|
49
|
+
|
|
50
|
+
## 6.44.11
|
|
51
|
+
|
|
52
|
+
Choose how Knowledge indexes: the embedding and the extraction tier.
|
|
53
|
+
|
|
54
|
+
- The setup checklist carries an `embedding` block (the chosen provider,
|
|
55
|
+
model and dimensions; whether the graph is locked to it; every provider's
|
|
56
|
+
readiness on this Mac; a fetch receipt) and an `extraction` block (the
|
|
57
|
+
tier and the three choices).
|
|
58
|
+
- `POST /api/context/graph/setup/embedding` `{ provider, model?, fetch? }`
|
|
59
|
+
chooses OpenAI large (the historical default), OpenAI small, a local premium
|
|
60
|
+
model through Ollama, or a local light model through fastembed/ONNX, and can
|
|
61
|
+
start the local model's fetch. One choice serves LightRAG and every Qdrant
|
|
62
|
+
collection. A graph built with another embedding answers 409
|
|
63
|
+
`embedding_locked`, and every indexing kickoff answers `embedding_mismatch`
|
|
64
|
+
rather than opening a dimension-bound store with the wrong vectors.
|
|
65
|
+
- `POST /api/context/graph/setup/extraction` `{ tier }` chooses Fast (Haiku),
|
|
66
|
+
Balanced (Sonnet) or Deep (Opus) for entity extraction; it takes effect on
|
|
67
|
+
the next run.
|
|
68
|
+
|
|
1
69
|
## 6.44.10
|
|
2
70
|
|
|
3
71
|
Ingest progress for COS Control.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.44.
|
|
3
|
+
"version": "6.44.13",
|
|
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": {
|
|
@@ -470,7 +470,8 @@ export function normalizeGraphBlock(value: unknown): GraphBlock | null {
|
|
|
470
470
|
// ── Recent learning payloads (GET /context/learning, /context/learning/:id) ──
|
|
471
471
|
|
|
472
472
|
export const LEARNING_EVENT_ID_PATTERN = /^evt_[a-f0-9]{16}$/
|
|
473
|
-
export const LEARNING_EVENT_TYPES = new Set(['captured', 'proposed', 'promotable', 'saved', 'retrieved', 'used', 'checked', 'dismissed', 'reverted', 'reopened', 'consolidated', 'previewed'])
|
|
473
|
+
export const LEARNING_EVENT_TYPES = new Set(['captured', 'proposed', 'promotable', 'saved', 'retrieved', 'used', 'checked', 'dismissed', 'reverted', 'reopened', 'consolidated', 'previewed', 'accepted', 'pruned'])
|
|
474
|
+
export const REVIEW_DECISIONS = new Set(['dismissed', 'reopened', 'accepted', 'pruned'])
|
|
474
475
|
const LEARNING_LIST_LIMIT = 50
|
|
475
476
|
/** The strict To review set is small (121 today); one page shows it whole. */
|
|
476
477
|
export const LEARNING_REVIEW_LIMIT = 200
|
|
@@ -783,7 +784,7 @@ export function normalizeReviewDecision(value: unknown): Record<string, unknown>
|
|
|
783
784
|
if (!row) return null
|
|
784
785
|
const decision = stringOrAbsent(row.decision, 16)
|
|
785
786
|
const lessonId = stringOrAbsent(row.lesson_id, 200)
|
|
786
|
-
if (!lessonId ||
|
|
787
|
+
if (!lessonId || !decision || !REVIEW_DECISIONS.has(decision)) return null
|
|
787
788
|
return {
|
|
788
789
|
lesson_id: lessonId,
|
|
789
790
|
decision,
|
|
@@ -853,6 +854,67 @@ function chosenPathOrAbsent(value: unknown, max = 1000): string | undefined {
|
|
|
853
854
|
export const KNOWLEDGE_ASK_MAX_CHARS = 400
|
|
854
855
|
|
|
855
856
|
export interface KnowledgeSetupCheck { id: string; ok: boolean; detail: string }
|
|
857
|
+
|
|
858
|
+
export const EMBEDDING_PROVIDERS = ['openai-large', 'openai-small', 'ollama', 'onnx'] as const
|
|
859
|
+
export const EXTRACTION_TIERS = ['haiku', 'sonnet', 'opus'] as const
|
|
860
|
+
|
|
861
|
+
/** `embedding` block of the setup status (6.44.11): the choice, the lock, every provider's readiness. */
|
|
862
|
+
export function normalizeEmbeddingBlock(value: unknown): Record<string, unknown> | null {
|
|
863
|
+
const e = asRecord(value)
|
|
864
|
+
if (!e) return null
|
|
865
|
+
const manifest = asRecord(e.manifest)
|
|
866
|
+
const fetch = asRecord(e.fetch)
|
|
867
|
+
const providers = (Array.isArray(e.providers) ? e.providers : []).flatMap((row) => {
|
|
868
|
+
const r = asRecord(row); const id = r ? stringOrAbsent(r.id, 24) : undefined
|
|
869
|
+
if (!id) return []
|
|
870
|
+
return [{
|
|
871
|
+
id, label: stringOrAbsent(r!.label, 40) ?? id, model: stringOrAbsent(r!.model, 120) ?? '', dimensions: integerOrAbsent(r!.dimensions) ?? null,
|
|
872
|
+
kind: r!.kind === 'local' ? 'local' : 'cloud', cost: stringOrAbsent(r!.cost, 200) ?? '', needs: stringOrAbsent(r!.needs, 200) ?? '',
|
|
873
|
+
ready: r!.ready === true, present: r!.present === true, detail: stringOrAbsent(r!.detail, 200) ?? '',
|
|
874
|
+
fix: stringOrAbsent(r!.fix, 300) ?? null, selected: r!.selected === true,
|
|
875
|
+
}]
|
|
876
|
+
})
|
|
877
|
+
// 6.44.12: the down-select. `preference.local_only` is the one question the
|
|
878
|
+
// user answers (may text leave this Mac?), `recommended` the mark it moves,
|
|
879
|
+
// `ready`/`fix` whether the chosen provider can embed right now and what
|
|
880
|
+
// would make it so. Absent on an older bridge: null, never a guess.
|
|
881
|
+
const preference = asRecord(e.preference)
|
|
882
|
+
const recommended = asRecord(e.recommended)
|
|
883
|
+
const recommendedId = recommended ? stringOrAbsent(recommended.id, 24) : undefined
|
|
884
|
+
return {
|
|
885
|
+
ready: e.ready === true,
|
|
886
|
+
fix: stringOrAbsent(e.fix, 300) ?? null,
|
|
887
|
+
preference: { local_only: preference && typeof preference.local_only === 'boolean' ? preference.local_only : null },
|
|
888
|
+
recommended: recommendedId ? {
|
|
889
|
+
id: recommendedId, label: stringOrAbsent(recommended!.label, 40) ?? recommendedId, reason: stringOrAbsent(recommended!.reason, 300) ?? '',
|
|
890
|
+
local_only: typeof recommended!.local_only === 'boolean' ? recommended!.local_only : null,
|
|
891
|
+
key_present: recommended!.key_present === true, ollama_present: recommended!.ollama_present === true,
|
|
892
|
+
} : null,
|
|
893
|
+
provider: stringOrAbsent(e.provider, 24) ?? null,
|
|
894
|
+
label: stringOrAbsent(e.label, 40) ?? null,
|
|
895
|
+
model: stringOrAbsent(e.model, 120) ?? null,
|
|
896
|
+
dimensions: integerOrAbsent(e.dimensions) ?? null,
|
|
897
|
+
kind: e.kind === 'local' ? 'local' : 'cloud',
|
|
898
|
+
cost: stringOrAbsent(e.cost, 200) ?? null,
|
|
899
|
+
chosen_at: isoOrAbsent(e.chosen_at) ?? null,
|
|
900
|
+
locked: e.locked === true,
|
|
901
|
+
mismatch: e.mismatch === true,
|
|
902
|
+
manifest: manifest ? { provider: stringOrAbsent(manifest.provider, 24) ?? null, model: stringOrAbsent(manifest.model, 120) ?? null, dimensions: integerOrAbsent(manifest.dimensions) ?? null, adopted: manifest.adopted === true } : null,
|
|
903
|
+
providers,
|
|
904
|
+
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,
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** `extraction` block: the tier and the three choices. */
|
|
909
|
+
export function normalizeExtractionBlock(value: unknown): Record<string, unknown> | null {
|
|
910
|
+
const x = asRecord(value)
|
|
911
|
+
if (!x) return null
|
|
912
|
+
const tiers = (Array.isArray(x.tiers) ? x.tiers : []).flatMap((row) => {
|
|
913
|
+
const r = asRecord(row); const id = r ? stringOrAbsent(r.id, 16) : undefined
|
|
914
|
+
return id ? [{ id, label: stringOrAbsent(r!.label, 40) ?? id, detail: stringOrAbsent(r!.detail, 200) ?? '', selected: r!.selected === true }] : []
|
|
915
|
+
})
|
|
916
|
+
return { tier: stringOrAbsent(x.tier, 16) ?? null, label: stringOrAbsent(x.label, 40) ?? null, detail: stringOrAbsent(x.detail, 200) ?? null, tiers }
|
|
917
|
+
}
|
|
856
918
|
export interface KnowledgeSetupSource { path: string; enabled: boolean; exists: boolean; files: number | null; added_at: string | null }
|
|
857
919
|
|
|
858
920
|
/** `graph-setup-status`: the checklist behind the Knowledge setup path. */
|
|
@@ -892,6 +954,8 @@ export function normalizeKnowledgeSetup(value: unknown): Record<string, unknown>
|
|
|
892
954
|
indexed: integerOrAbsent(sample.indexed) ?? 0,
|
|
893
955
|
},
|
|
894
956
|
lock: normalizeIngestKickoff({ lock: s.lock }).lock,
|
|
957
|
+
embedding: normalizeEmbeddingBlock(s.embedding),
|
|
958
|
+
extraction: normalizeExtractionBlock(s.extraction),
|
|
895
959
|
ask_ready: s.ask_ready === true,
|
|
896
960
|
protocol: integerOrAbsent(s.protocol) ?? null,
|
|
897
961
|
}
|
|
@@ -955,6 +1019,47 @@ export function normalizeIngestProgress(value: unknown): Record<string, unknown>
|
|
|
955
1019
|
}
|
|
956
1020
|
}
|
|
957
1021
|
|
|
1022
|
+
export const GUARDRAIL_LLM_TIERS = ['haiku', 'sonnet', 'opus'] as const
|
|
1023
|
+
|
|
1024
|
+
/** The user's memory guardrails (6.44.13), every field bounded. */
|
|
1025
|
+
export function normalizeGuardrails(value: unknown): Record<string, unknown> | null {
|
|
1026
|
+
const g = asRecord(value)
|
|
1027
|
+
if (!g) return null
|
|
1028
|
+
const llm = asRecord(g.llm_review) ?? {}
|
|
1029
|
+
return {
|
|
1030
|
+
min_words: integerOrAbsent(g.min_words) ?? 6,
|
|
1031
|
+
min_distinct_chars: integerOrAbsent(g.min_distinct_chars) ?? 8,
|
|
1032
|
+
max_repeat_ratio: typeof g.max_repeat_ratio === 'number' && Number.isFinite(g.max_repeat_ratio) ? g.max_repeat_ratio : 0.5,
|
|
1033
|
+
banned_patterns: (Array.isArray(g.banned_patterns) ? g.banned_patterns : []).filter((p): p is string => typeof p === 'string').slice(0, 50).map(p => p.slice(0, 200)),
|
|
1034
|
+
llm_review: {
|
|
1035
|
+
enabled: llm.enabled === true,
|
|
1036
|
+
model: (GUARDRAIL_LLM_TIERS as readonly string[]).includes(String(llm.model)) ? String(llm.model) : 'haiku',
|
|
1037
|
+
max_per_run: integerOrAbsent(llm.max_per_run) ?? 20,
|
|
1038
|
+
},
|
|
1039
|
+
updated_at: isoOrAbsent(g.updated_at) ?? null,
|
|
1040
|
+
by: stringOrAbsent(g.by, 32) ?? null,
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** One guardrails run: the receipt and the verdicts with their reasons. */
|
|
1045
|
+
export function normalizeGuardrailsRun(value: unknown): Record<string, unknown> {
|
|
1046
|
+
const s = asRecord(value) ?? {}
|
|
1047
|
+
const r = asRecord(s.run) ?? {}
|
|
1048
|
+
const verdicts = (Array.isArray(s.verdicts) ? s.verdicts : []).flatMap((row) => {
|
|
1049
|
+
const v = asRecord(row); const id = v ? stringOrAbsent(v.id, 200) : undefined
|
|
1050
|
+
if (!id) return []
|
|
1051
|
+
const verdict = v!.verdict === 'prune' || v!.verdict === 'keep' || v!.verdict === 'review' ? v!.verdict : 'review'
|
|
1052
|
+
return [{ id, type: stringOrAbsent(v!.type, 32) ?? '', created_at: isoOrAbsent(v!.created_at) ?? null, excerpt: typeof v!.excerpt === 'string' ? v!.excerpt.slice(0, 200) : '', verdict,
|
|
1053
|
+
reasons: (Array.isArray(v!.reasons) ? v!.reasons : []).filter((x): x is string => typeof x === 'string').slice(0, 8).map(x => x.slice(0, 300)), by: stringOrAbsent(v!.by, 16) ?? 'rules', applied: v!.applied === true }]
|
|
1054
|
+
}).slice(0, 500)
|
|
1055
|
+
return {
|
|
1056
|
+
run: { id: stringOrAbsent(r.id, 64) ?? null, started_at: isoOrAbsent(r.started_at) ?? null, ended_at: isoOrAbsent(r.ended_at) ?? null, days: integerOrAbsent(r.days) ?? null,
|
|
1057
|
+
scanned: integerOrAbsent(r.scanned) ?? 0, flagged: integerOrAbsent(r.flagged) ?? 0, review: integerOrAbsent(r.review) ?? 0, kept: integerOrAbsent(r.kept) ?? 0,
|
|
1058
|
+
llm_reviewed: integerOrAbsent(r.llm_reviewed) ?? 0, llm_enabled: r.llm_enabled === true, applied: r.applied === true, pruned: integerOrAbsent(r.pruned) ?? 0 },
|
|
1059
|
+
verdicts,
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
958
1063
|
export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
|
|
959
1064
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
960
1065
|
? value as Record<string, unknown> : {}
|
|
@@ -65,6 +65,11 @@ export const LEARNING_COMMANDS = [
|
|
|
65
65
|
'graph-ask',
|
|
66
66
|
'graph-schedule',
|
|
67
67
|
'graph-ingest-progress',
|
|
68
|
+
'graph-setup-embedding',
|
|
69
|
+
'graph-setup-extraction',
|
|
70
|
+
'memory-review',
|
|
71
|
+
'memory-guardrails',
|
|
72
|
+
'memory-guardrails-run',
|
|
68
73
|
] as const
|
|
69
74
|
|
|
70
75
|
// The optional Python bridge is available only when the user points us at a real
|
|
@@ -246,6 +251,11 @@ function standaloneNoop(args: string[]): unknown {
|
|
|
246
251
|
case 'graph-ask':
|
|
247
252
|
case 'graph-schedule':
|
|
248
253
|
case 'graph-ingest-progress':
|
|
254
|
+
case 'graph-setup-embedding':
|
|
255
|
+
case 'graph-setup-extraction':
|
|
256
|
+
case 'memory-review':
|
|
257
|
+
case 'memory-guardrails':
|
|
258
|
+
case 'memory-guardrails-run':
|
|
249
259
|
return { error: 'cos_pipeline_not_configured' }
|
|
250
260
|
case 'task-rows':
|
|
251
261
|
case 'task-capture':
|
package/server/routes/memory.ts
CHANGED
|
@@ -30,6 +30,12 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
|
|
|
30
30
|
normalizeSampleKickoff,
|
|
31
31
|
normalizeGraphAnswer,
|
|
32
32
|
normalizeIngestProgress,
|
|
33
|
+
normalizeEmbeddingBlock,
|
|
34
|
+
normalizeExtractionBlock,
|
|
35
|
+
normalizeGuardrails,
|
|
36
|
+
normalizeGuardrailsRun,
|
|
37
|
+
EMBEDDING_PROVIDERS,
|
|
38
|
+
EXTRACTION_TIERS,
|
|
33
39
|
KNOWLEDGE_SETUP_SAMPLE_MAX,
|
|
34
40
|
KNOWLEDGE_ASK_MAX_CHARS,
|
|
35
41
|
normalizeLearningEventDetail,
|
|
@@ -197,6 +203,92 @@ memoryRouter.post('/context/learning/:id/review', async (req, res) => {
|
|
|
197
203
|
}
|
|
198
204
|
})
|
|
199
205
|
|
|
206
|
+
// ── Memory review and guardrails (6.44.13) ──────────────────────────
|
|
207
|
+
//
|
|
208
|
+
// Accept stamps a captured memory as reviewed; prune deletes it. Both are
|
|
209
|
+
// review-ledger rows the timeline shows. The guardrails are the user's own
|
|
210
|
+
// rules; a run scans, judges (rules, then an optional bounded model pass),
|
|
211
|
+
// and prunes only with apply.
|
|
212
|
+
|
|
213
|
+
memoryRouter.post('/context/memory/:id/review', async (req, res) => {
|
|
214
|
+
noStore(res)
|
|
215
|
+
const memoryId = String(req.params.id)
|
|
216
|
+
const decision = typeof req.body?.decision === 'string' ? req.body.decision : ''
|
|
217
|
+
if (!memoryId || memoryId.length > 200 || CONTROL_CHARACTER.test(memoryId)) { res.status(400).json({ error: 'invalid_memory_id' }); return }
|
|
218
|
+
if (decision !== 'accept' && decision !== 'prune') { res.status(400).json({ error: 'invalid_decision', message: 'decision must be accept or prune' }); return }
|
|
219
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
220
|
+
try {
|
|
221
|
+
const note = typeof req.body?.note === 'string' ? req.body.note.slice(0, 400) : ''
|
|
222
|
+
const answer = await callPython(['memory-review', `--id=${memoryId}`, `--decision=${decision}`, ...(note ? [`--note=${note}`] : [])], 20_000)
|
|
223
|
+
const code = bridgeErrorCode(answer)
|
|
224
|
+
if (code) { sendSetupError(res, code, answer); return }
|
|
225
|
+
const a = answer as { decision?: unknown; deleted?: unknown }
|
|
226
|
+
const row = normalizeReviewDecision({ decision: a.decision })
|
|
227
|
+
if (!row) { res.status(503).json({ error: 'memory_review_unavailable' }); return }
|
|
228
|
+
res.json({ decision: row, deleted: a.deleted === true })
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.warn('[context] memory review bridge failure:', (error as Error).message)
|
|
231
|
+
res.status(503).json({ error: 'memory_unavailable' })
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
memoryRouter.get('/context/memory-guardrails', async (_req, res) => {
|
|
236
|
+
noStore(res)
|
|
237
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
238
|
+
try {
|
|
239
|
+
const answer = await callPython(['memory-guardrails'], 15_000)
|
|
240
|
+
const code = bridgeErrorCode(answer)
|
|
241
|
+
if (code) { sendSetupError(res, code, answer); return }
|
|
242
|
+
const a = answer as { guardrails?: unknown; philosophy_rubric_lines?: unknown }
|
|
243
|
+
res.json({ guardrails: normalizeGuardrails(a.guardrails), philosophy_rubric_lines: Number.isInteger(a.philosophy_rubric_lines) ? a.philosophy_rubric_lines : 0 })
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.warn('[context] guardrails bridge failure:', (error as Error).message)
|
|
246
|
+
res.status(503).json({ error: 'memory_unavailable' })
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
memoryRouter.put('/context/memory-guardrails', async (req, res) => {
|
|
251
|
+
noStore(res)
|
|
252
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
253
|
+
const body = req.body
|
|
254
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) { res.status(400).json({ error: 'invalid_guardrails', message: 'the body must be a JSON object' }); return }
|
|
255
|
+
const text = JSON.stringify(body)
|
|
256
|
+
if (text.length > 16_000) { res.status(400).json({ error: 'invalid_guardrails', message: 'the guardrails patch is too large' }); return }
|
|
257
|
+
try {
|
|
258
|
+
const answer = await callPython(['memory-guardrails', '--stdin'], 15_000, text)
|
|
259
|
+
const code = bridgeErrorCode(answer)
|
|
260
|
+
if (code) { sendSetupError(res, code, answer); return }
|
|
261
|
+
const a = answer as { guardrails?: unknown; philosophy_rubric_lines?: unknown }
|
|
262
|
+
res.json({ guardrails: normalizeGuardrails(a.guardrails), philosophy_rubric_lines: Number.isInteger(a.philosophy_rubric_lines) ? a.philosophy_rubric_lines : 0 })
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.warn('[context] guardrails bridge failure:', (error as Error).message)
|
|
265
|
+
res.status(503).json({ error: 'memory_unavailable' })
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
/** `{ days?, apply?, llm? }` → scan the captured memories; prune the flagged ones only with apply. Bounded: 30 days, one model pass of max_per_run. */
|
|
270
|
+
memoryRouter.post('/context/memory-guardrails/run', async (req, res) => {
|
|
271
|
+
noStore(res)
|
|
272
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
273
|
+
const body = (req.body ?? {}) as { days?: unknown; apply?: unknown; llm?: unknown }
|
|
274
|
+
const days = body.days === undefined || body.days === null ? 30 : Number(body.days)
|
|
275
|
+
if (!Number.isInteger(days) || days < 1 || days > 3650) { res.status(400).json({ error: 'invalid_days', message: 'days must be an integer from 1 to 3650' }); return }
|
|
276
|
+
if (body.apply !== undefined && typeof body.apply !== 'boolean') { res.status(400).json({ error: 'invalid_apply', message: 'apply must be true or false' }); return }
|
|
277
|
+
if (body.llm !== undefined && body.llm !== null && typeof body.llm !== 'boolean') { res.status(400).json({ error: 'invalid_llm', message: 'llm must be true or false' }); return }
|
|
278
|
+
const argv = ['memory-guardrails-run', `--days=${days}`]
|
|
279
|
+
if (body.apply === true) argv.push('--apply')
|
|
280
|
+
if (typeof body.llm === 'boolean') argv.push(`--llm=${body.llm}`)
|
|
281
|
+
try {
|
|
282
|
+
const answer = await callPython(argv, body.llm === false ? 60_000 : 400_000)
|
|
283
|
+
const code = bridgeErrorCode(answer)
|
|
284
|
+
if (code) { sendSetupError(res, code, answer); return }
|
|
285
|
+
res.json(normalizeGuardrailsRun(answer))
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.warn('[context] guardrails run bridge failure:', (error as Error).message)
|
|
288
|
+
res.status(503).json({ error: 'memory_unavailable' })
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
200
292
|
memoryRouter.get('/context/learning/:id', async (req, res) => {
|
|
201
293
|
noStore(res)
|
|
202
294
|
if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
|
|
@@ -331,14 +423,16 @@ memoryRouter.post('/context/graph/ingest', async (req, res) => {
|
|
|
331
423
|
return
|
|
332
424
|
}
|
|
333
425
|
try {
|
|
334
|
-
|
|
426
|
+
// 20 s, not 5: the kickoff gates on the embedding's readiness, which probes Ollama
|
|
427
|
+
// (2 s bound) before it spawns. The spawn itself still returns at once (QA 2026-09-07).
|
|
428
|
+
const data = await callPython(['graph-ingest-start', `--limit=${limit}`, '--reason=control'], 20_000)
|
|
335
429
|
const code = bridgeErrorCode(data)
|
|
336
430
|
if (code === 'not_owner') {
|
|
337
431
|
const detail = data as { message?: unknown; owner_host?: unknown }
|
|
338
432
|
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
433
|
return
|
|
340
434
|
}
|
|
341
|
-
if (code) { res
|
|
435
|
+
if (code) { sendSetupError(res, code, data); return }
|
|
342
436
|
res.status(202).json(normalizeIngestKickoff(data))
|
|
343
437
|
} catch (error) {
|
|
344
438
|
console.warn('[context] ingest bridge failure:', (error as Error).message)
|
|
@@ -372,6 +466,14 @@ memoryRouter.get('/context/graph/ingest/progress', async (_req, res) => {
|
|
|
372
466
|
function sendSetupError(res: import('express').Response, code: string, data: unknown): void {
|
|
373
467
|
const detail = asDetail(data)
|
|
374
468
|
if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
|
|
469
|
+
if (code === 'embedding_locked' || code === 'embedding_mismatch') { res.status(409).json({ error: code, message: detail.message }); return }
|
|
470
|
+
if (code === 'embedding_not_ready') {
|
|
471
|
+
// 6.44.12: the chosen embedding cannot embed on this Mac right now. The
|
|
472
|
+
// bridge refused before spawning; pass its fix through so the page can show it.
|
|
473
|
+
const d = (typeof data === 'object' && data !== null ? data : {}) as { provider?: unknown; fix?: unknown }
|
|
474
|
+
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 })
|
|
475
|
+
return
|
|
476
|
+
}
|
|
375
477
|
if (code.endsWith('_not_found')) { res.status(404).json({ error: code, message: detail.message }); return }
|
|
376
478
|
if (code.startsWith('invalid_')) { res.status(400).json({ error: code, message: detail.message }); return }
|
|
377
479
|
res.status(503).json({ error: code })
|
|
@@ -468,6 +570,63 @@ memoryRouter.post('/context/graph/ask', async (req, res) => {
|
|
|
468
570
|
}
|
|
469
571
|
})
|
|
470
572
|
|
|
573
|
+
/**
|
|
574
|
+
* `{ provider?, model?, fetch?, local_only? }` → the embedding for every knowledge store (6.44.11). 409 when the graph was built with another.
|
|
575
|
+
* 6.44.12: `local_only` (boolean) records the one question the down-select asks, may text leave this Mac; alone it moves the
|
|
576
|
+
* Recommended mark and changes no choice. One of `provider` or `local_only` is required.
|
|
577
|
+
*/
|
|
578
|
+
memoryRouter.post('/context/graph/setup/embedding', async (req, res) => {
|
|
579
|
+
noStore(res)
|
|
580
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
581
|
+
const body = (req.body ?? {}) as { provider?: unknown; model?: unknown; fetch?: unknown; local_only?: unknown }
|
|
582
|
+
const provider = typeof body.provider === 'string' ? body.provider : ''
|
|
583
|
+
const hasPreference = body.local_only !== undefined && body.local_only !== null
|
|
584
|
+
if (hasPreference && typeof body.local_only !== 'boolean') { res.status(400).json({ error: 'invalid_local_only', message: 'local_only must be true or false' }); return }
|
|
585
|
+
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 }
|
|
586
|
+
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 }
|
|
587
|
+
const model = typeof body.model === 'string' ? body.model.trim() : ''
|
|
588
|
+
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 }
|
|
589
|
+
const argv = ['graph-setup-embedding']
|
|
590
|
+
if (provider) argv.push(`--provider=${provider}`)
|
|
591
|
+
if (hasPreference) argv.push(`--local-only=${body.local_only === true ? 'true' : 'false'}`)
|
|
592
|
+
if (model) argv.push(`--model=${model}`)
|
|
593
|
+
if (body.fetch === true) argv.push('--fetch')
|
|
594
|
+
try {
|
|
595
|
+
const data = await callPython(argv, 90_000)
|
|
596
|
+
const code = bridgeErrorCode(data)
|
|
597
|
+
if (code) { sendSetupError(res, code, data); return }
|
|
598
|
+
const d = data as { embedding?: unknown; fetch?: unknown }
|
|
599
|
+
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 })
|
|
600
|
+
} catch (error) {
|
|
601
|
+
console.warn('[context] embedding bridge failure:', (error as Error).message)
|
|
602
|
+
res.status(503).json({ error: 'graph_unavailable' })
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
/** `{ tier }` → the extraction tier: haiku (Fast), sonnet (Balanced), opus (Deep). */
|
|
607
|
+
memoryRouter.post('/context/graph/setup/extraction', async (req, res) => {
|
|
608
|
+
noStore(res)
|
|
609
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
610
|
+
const tier = typeof (req.body as { tier?: unknown } | undefined)?.tier === 'string' ? (req.body as { tier: string }).tier : ''
|
|
611
|
+
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 }
|
|
612
|
+
try {
|
|
613
|
+
const data = await callPython(['graph-setup-extraction', `--tier=${tier}`], 15_000)
|
|
614
|
+
const code = bridgeErrorCode(data)
|
|
615
|
+
if (code) { sendSetupError(res, code, data); return }
|
|
616
|
+
res.json({ extraction: normalizeExtractionBlock((data as { extraction?: unknown }).extraction) })
|
|
617
|
+
} catch (error) {
|
|
618
|
+
console.warn('[context] extraction bridge failure:', (error as Error).message)
|
|
619
|
+
res.status(503).json({ error: 'graph_unavailable' })
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
624
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null
|
|
625
|
+
}
|
|
626
|
+
function integerOrNull(value: unknown): number | null {
|
|
627
|
+
return typeof value === 'number' && Number.isInteger(value) ? value : null
|
|
628
|
+
}
|
|
629
|
+
|
|
471
630
|
/** `{ enabled, interval_s? }` → install or remove the scheduled batch agent on the owner Mac. */
|
|
472
631
|
memoryRouter.post('/context/graph/setup/schedule', async (req, res) => {
|
|
473
632
|
noStore(res)
|