@gotcos/glasses-server 6.44.6 → 6.44.7

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,13 @@
1
+ ## 6.44.7
2
+
3
+ One learning write: a review decision.
4
+
5
+ - `POST /api/context/learning/:id/review` with `{ "decision": "dismissed" | "reopened" }`
6
+ appends a review-ledger row through the bridge's `learning-decide`. A
7
+ dismissed proposal leaves the To review set on the next read; reopened puts
8
+ it back. Nothing else is written: no skill, no memory, no graph. COS Control
9
+ 0.5.191's Memories tab uses it for Dismiss and Restore proposal.
10
+
1
11
  ## 6.44.6
2
12
 
3
13
  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.7",
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 {
@@ -56,6 +56,7 @@ export const LEARNING_COMMANDS = [
56
56
  'graph-passages',
57
57
  'graph-index-build',
58
58
  'learning-to-review',
59
+ 'learning-decide',
59
60
  ] as const
60
61
 
61
62
  // The optional Python bridge is available only when the user points us at a real
@@ -228,6 +229,7 @@ function standaloneNoop(args: string[]): unknown {
228
229
  case 'graph-passages':
229
230
  case 'graph-index-build':
230
231
  case 'learning-to-review':
232
+ case 'learning-decide':
231
233
  return { error: 'cos_pipeline_not_configured' }
232
234
  case 'task-rows':
233
235
  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,
@@ -167,6 +167,26 @@ memoryRouter.get('/context/learning/review', async (req, res) => {
167
167
  }
168
168
  })
169
169
 
170
+ memoryRouter.post('/context/learning/:id/review', async (req, res) => {
171
+ noStore(res)
172
+ // The one learning write (6.44.7): a review decision on a lesson, appended to
173
+ // the review ledger by the bridge. Dismissed leaves To review, reopened
174
+ // returns; nothing else is touched. The lesson id is a store id, not an event id.
175
+ const lessonId = String(req.params.id)
176
+ const decision = typeof req.body?.decision === 'string' ? req.body.decision : ''
177
+ if (!lessonId || lessonId.length > 200 || CONTROL_CHARACTER.test(lessonId)) { res.status(400).json({ error: 'invalid_lesson_id' }); return }
178
+ if (decision !== 'dismissed' && decision !== 'reopened') { res.status(400).json({ error: 'invalid_decision' }); return }
179
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
180
+ try {
181
+ const note = typeof req.body?.note === 'string' ? req.body.note.slice(0, 400) : ''
182
+ const answer = await callPython(['learning-decide', `--id=${lessonId}`, `--decision=${decision}`, ...(note ? [`--note=${note}`] : [])], 8_000)
183
+ sendBridgeAnswer(res, answer, value => normalizeReviewDecision(value))
184
+ } catch (error) {
185
+ console.warn('[context] learning decide bridge failure:', (error as Error).message)
186
+ res.status(503).json({ error: 'learning_unavailable' })
187
+ }
188
+ })
189
+
170
190
  memoryRouter.get('/context/learning/:id', async (req, res) => {
171
191
  noStore(res)
172
192
  if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }