@gotcos/glasses-server 6.44.12 → 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 CHANGED
@@ -1,3 +1,26 @@
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
+
1
24
  ## 6.44.12
2
25
 
3
26
  The Knowledge down-select, and a fail-closed embedding gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.12",
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 || (decision !== 'dismissed' && decision !== 'reopened')) return null
787
+ if (!lessonId || !decision || !REVIEW_DECISIONS.has(decision)) return null
787
788
  return {
788
789
  lesson_id: lessonId,
789
790
  decision,
@@ -1018,6 +1019,47 @@ export function normalizeIngestProgress(value: unknown): Record<string, unknown>
1018
1019
  }
1019
1020
  }
1020
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
+
1021
1063
  export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
1022
1064
  const source = value && typeof value === 'object' && !Array.isArray(value)
1023
1065
  ? value as Record<string, unknown> : {}
@@ -67,6 +67,9 @@ export const LEARNING_COMMANDS = [
67
67
  'graph-ingest-progress',
68
68
  'graph-setup-embedding',
69
69
  'graph-setup-extraction',
70
+ 'memory-review',
71
+ 'memory-guardrails',
72
+ 'memory-guardrails-run',
70
73
  ] as const
71
74
 
72
75
  // The optional Python bridge is available only when the user points us at a real
@@ -250,6 +253,9 @@ function standaloneNoop(args: string[]): unknown {
250
253
  case 'graph-ingest-progress':
251
254
  case 'graph-setup-embedding':
252
255
  case 'graph-setup-extraction':
256
+ case 'memory-review':
257
+ case 'memory-guardrails':
258
+ case 'memory-guardrails-run':
253
259
  return { error: 'cos_pipeline_not_configured' }
254
260
  case 'task-rows':
255
261
  case 'task-capture':
@@ -32,6 +32,8 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
32
32
  normalizeIngestProgress,
33
33
  normalizeEmbeddingBlock,
34
34
  normalizeExtractionBlock,
35
+ normalizeGuardrails,
36
+ normalizeGuardrailsRun,
35
37
  EMBEDDING_PROVIDERS,
36
38
  EXTRACTION_TIERS,
37
39
  KNOWLEDGE_SETUP_SAMPLE_MAX,
@@ -201,6 +203,92 @@ memoryRouter.post('/context/learning/:id/review', async (req, res) => {
201
203
  }
202
204
  })
203
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
+
204
292
  memoryRouter.get('/context/learning/:id', async (req, res) => {
205
293
  noStore(res)
206
294
  if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }