@gotcos/glasses-server 6.44.5 → 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,38 @@
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
+
11
+ ## 6.44.6
12
+
13
+ What /qa found in 6.44.5 before anyone installed it.
14
+
15
+ - `GET /api/context/learning/review`: the strict To review set (the bridge's
16
+ `learning-to-review`, the same set the status block counts) as event rows,
17
+ one page of up to 200. 6.44.5's list approximated it with a kind filter and
18
+ showed "50 of 837" beside a chip that said 121.
19
+ - Every user value now rides as one argv token (`--q=...`, `--id=...`,
20
+ `--entity=...`), so a search term or entity name that begins with `-` can no
21
+ longer be read by the bridge's argument parser as a flag.
22
+ - An older COS bridge prints its unknown-command answer to stdout; the server
23
+ probed only stderr, so every learning route on such a bridge answered 503
24
+ instead of `cos_pipeline_not_configured`. Both are probed now.
25
+ - A `found: false` entity answer is a 404 with a class Control can name,
26
+ `index_missing` or `entity_not_found`, never a 503; the null-normalizer
27
+ fallback says `record_not_found`.
28
+ - `/api/context/status` answers `Cache-Control: private, no-store` like the
29
+ eight routes that shipped with it; it carries private counts.
30
+ - Every learning and graph route logs the bridge failure it used to swallow,
31
+ and the search shape passes the bridge's own `scope` through.
32
+ - On the COS side (same night): a JSON error exits 0 so it is parsed rather
33
+ than rejected, the bot-memory reach is bounded at 2.5 s so a wedged Qdrant
34
+ cannot blank the file stores, and entity descriptions cap at 12.
35
+
1
36
  ## 6.44.5
2
37
 
3
38
  Recent learning and the knowledge graph, read-only, for COS Control.
@@ -8,7 +43,7 @@ Recent learning and the knowledge graph, read-only, for COS Control.
8
43
  the command, times out, or answers `{ error }` leaves `memory` and `threads`
9
44
  exactly as they were and the two blocks absent. Nothing an existing client
10
45
  reads has changed; a client that wants the blocks checks for them.
11
- - Eight read routes: `GET /api/context/learning` (cursor-paged events with a
46
+ - Seven read routes: `GET /api/context/learning` (cursor-paged events with a
12
47
  per-store coverage map), `GET /api/context/learning/status`,
13
48
  `GET /api/context/learning/:id`, `GET /api/context/graph/status`,
14
49
  `GET /api/context/graph/search`, `GET /api/context/graph/entity`,
package/README.md CHANGED
@@ -425,7 +425,7 @@ coverage map), `/api/context/learning/status`, `/api/context/learning/:id`, and
425
425
  `/api/context/graph/{status,search,entity,passages}`, plus `POST
426
426
  /api/context/graph/index`, which only asks the pipeline to start a detached index
427
427
  build and answers 202. Nothing in the file tier can answer these, so they return
428
- 503 with the bridge state there; older servers 404 them, which is how a client
428
+ 503 `cos_pipeline_not_configured` there; older servers 404 them, which is how a client
429
429
  tells the versions apart. `/api/context/status` carries `learning` and `graph`
430
430
  blocks when the bridge can produce them and omits them otherwise.
431
431
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.5",
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": {
@@ -472,6 +472,8 @@ export function normalizeGraphBlock(value: unknown): GraphBlock | null {
472
472
  export const LEARNING_EVENT_ID_PATTERN = /^evt_[a-f0-9]{16}$/
473
473
  export const LEARNING_EVENT_TYPES = new Set(['captured', 'proposed', 'promotable', 'saved', 'retrieved', 'used', 'checked', 'dismissed', 'reverted', 'reopened', 'consolidated', 'previewed'])
474
474
  const LEARNING_LIST_LIMIT = 50
475
+ /** The strict To review set is small (121 today); one page shows it whole. */
476
+ export const LEARNING_REVIEW_LIMIT = 200
475
477
  const LEARNING_DETAIL_KEYS = ['shape', 'task', 'kind', 'layer', 'date', 'future', 'logged_times', 'memory_type', 'capture', 'source', 'content', 'before', 'after', 'rule', 'status', 'occurrences', 'threshold', 'entry', 'truncated'] as const
476
478
 
477
479
  export interface LearningEvent {
@@ -553,11 +555,11 @@ export function normalizeLearningCoverage(value: unknown): LearningCoverage {
553
555
  return out
554
556
  }
555
557
 
556
- export function normalizeLearningEvents(value: unknown, limit: number): {
558
+ export function normalizeLearningEvents(value: unknown, limit: number, max = LEARNING_LIST_LIMIT): {
557
559
  events: LearningEvent[]; total: number; next_cursor: { since_ts: string; since_event_id: string } | null; coverage: LearningCoverage
558
560
  } {
559
561
  const source = asRecord(value) ?? {}
560
- const cap = Math.max(1, Math.min(limit, LEARNING_LIST_LIMIT))
562
+ const cap = Math.max(1, Math.min(limit, max))
561
563
  const events = (Array.isArray(source.events) ? source.events : []).slice(0, cap)
562
564
  .map(item => normalizeLearningEvent(item, 240)).filter((e): e is LearningEvent => !!e)
563
565
  const cursor = asRecord(source.next_cursor)
@@ -568,6 +570,7 @@ export function normalizeLearningEvents(value: unknown, limit: number): {
568
570
  total: integerOrAbsent(source.total) ?? events.length,
569
571
  next_cursor: sinceTs && sinceId ? { since_ts: sinceTs, since_event_id: sinceId } : null,
570
572
  coverage: normalizeLearningCoverage(source.coverage),
573
+ ...(integerOrAbsent(source.review_count) !== undefined ? { review_count: integerOrAbsent(source.review_count) } : {}),
571
574
  }
572
575
  }
573
576
 
@@ -579,7 +582,6 @@ export function normalizeLearningEventDetail(value: unknown): (LearningEvent & {
579
582
  for (const key of LEARNING_DETAIL_KEYS) {
580
583
  const item = raw[key]
581
584
  if (item === undefined || item === null) continue
582
- if (key === 'bodies' as string) continue
583
585
  if (typeof item === 'boolean') detail[key] = item
584
586
  else if (Number.isInteger(item)) detail[key] = item
585
587
  else if (typeof item === 'string') detail[key] = cleanContextText(item, 1200)
@@ -708,7 +710,7 @@ export function normalizeGraphSearch(value: unknown, limit: number): Record<stri
708
710
  return {
709
711
  items,
710
712
  total: integerOrAbsent(source.total) ?? items.length,
711
- scope: 'full-index',
713
+ scope: stringOrAbsent(source.scope, 32) ?? 'full-index',
712
714
  index_built_at: isoOrAbsent(source.index_built_at) ?? null,
713
715
  index_state: stringOrAbsent(source.index_state, 32) ?? 'missing',
714
716
  matcher: stringOrAbsent(source.matcher, 16) ?? null,
@@ -774,6 +776,23 @@ export function normalizeGraphPassages(value: unknown): Record<string, unknown>
774
776
  }
775
777
  }
776
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
+
777
796
  export function normalizeIndexBuildKickoff(value: unknown): { started: boolean; already_running: boolean; pid: number | null; receipt: Record<string, unknown> | null } {
778
797
  const source = asRecord(value) ?? {}
779
798
  return {
@@ -55,6 +55,8 @@ export const LEARNING_COMMANDS = [
55
55
  'graph-entity',
56
56
  'graph-passages',
57
57
  'graph-index-build',
58
+ 'learning-to-review',
59
+ 'learning-decide',
58
60
  ] as const
59
61
 
60
62
  // The optional Python bridge is available only when the user points us at a real
@@ -226,6 +228,8 @@ function standaloneNoop(args: string[]): unknown {
226
228
  case 'graph-entity':
227
229
  case 'graph-passages':
228
230
  case 'graph-index-build':
231
+ case 'learning-to-review':
232
+ case 'learning-decide':
229
233
  return { error: 'cos_pipeline_not_configured' }
230
234
  case 'task-rows':
231
235
  case 'task-capture':
@@ -255,7 +259,10 @@ function callPythonDirect(args: string[], timeoutMs: number, input?: string): Pr
255
259
  { cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
256
260
  (err, stdout, stderr) => {
257
261
  if (err) {
258
- const msg = stderr?.trim() || err.message
262
+ // The bridge prints its unknown-command answer to STDOUT and exits 1
263
+ // (cos_api_bridge.py), so an older checkout must be probed there too, or
264
+ // every learning route answers 503 instead of not-configured (QA 2026-09-06).
265
+ const msg = stderr?.trim() || stdout?.trim() || err.message
259
266
  if (typeof msg === 'string' && msg.includes('unknown command')) {
260
267
  return resolvePromise({ error: { code: 'cos_pipeline_not_configured', message: msg } })
261
268
  }
@@ -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 {
16
+ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
17
17
  GRAPH_ENTITY_ID_LIMIT,
18
18
  LEARNING_EVENT_ID_PATTERN,
19
19
  MEMORY_ID_PATTERN,
@@ -35,6 +35,7 @@ export const memoryRouter = Router()
35
35
  let overviewCache: { expiresAt: number; value: ReturnType<typeof normalizeMemoryOverview> } | null = null
36
36
 
37
37
  memoryRouter.get('/context/status', async (_req, res) => {
38
+ noStore(res)
38
39
  if (!contextConfigured()) {
39
40
  const state = pythonBridgeState()
40
41
  res.json(normalizeContextBrowserStatus({
@@ -105,7 +106,7 @@ function sendBridgeAnswer(res: import('express').Response, data: unknown, normal
105
106
  }
106
107
  const value = normalize(data)
107
108
  if (value === null || value === undefined) {
108
- res.status(404).json({ error: 'not_found' })
109
+ res.status(404).json({ error: 'record_not_found' })
109
110
  return
110
111
  }
111
112
  res.json(value)
@@ -124,7 +125,8 @@ memoryRouter.get('/context/learning/status', async (_req, res) => {
124
125
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
125
126
  try {
126
127
  sendBridgeAnswer(res, await callPython(['learning-status', '--no-memory'], 8_000), normalizeLearningStatus)
127
- } catch {
128
+ } catch (error) {
129
+ console.warn('[context] learning bridge failure:', (error as Error).message)
128
130
  res.status(503).json({ error: 'learning_unavailable' })
129
131
  }
130
132
  })
@@ -137,13 +139,50 @@ memoryRouter.get('/context/learning', async (req, res) => {
137
139
  const sinceTs = typeof req.query.since_ts === 'string' && Number.isFinite(Date.parse(req.query.since_ts)) ? req.query.since_ts.slice(0, 40) : ''
138
140
  const sinceId = typeof req.query.since_event_id === 'string' && LEARNING_EVENT_ID_PATTERN.test(req.query.since_event_id) ? req.query.since_event_id : ''
139
141
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
140
- const args = ['learning-events', '--days', String(days), '--limit', String(limit)]
141
- if (kind) args.push('--kind', kind)
142
- if (sinceTs) args.push('--since-ts', sinceTs)
143
- if (sinceId) args.push('--since-event-id', sinceId)
142
+ const args = ['learning-events', `--days=${days}`, `--limit=${limit}`]
143
+ if (kind) args.push(`--kind=${kind}`)
144
+ if (sinceTs) args.push(`--since-ts=${sinceTs}`)
145
+ if (sinceId) args.push(`--since-event-id=${sinceId}`)
144
146
  try {
145
147
  sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeLearningEvents(value, limit))
146
- } catch {
148
+ } catch (error) {
149
+ console.warn('[context] learning bridge failure:', (error as Error).message)
150
+ res.status(503).json({ error: 'learning_unavailable' })
151
+ }
152
+ })
153
+
154
+ memoryRouter.get('/context/learning/review', async (req, res) => {
155
+ noStore(res)
156
+ // The strict To review set (learning_events.to_review) as event rows, so the
157
+ // chip, Doctor and the list count the same thing. Small set: one page.
158
+ const limit = boundedInteger(req.query.limit, LEARNING_REVIEW_LIMIT, 1, LEARNING_REVIEW_LIMIT)
159
+ const days = req.query.days === undefined ? null : boundedInteger(req.query.days, 3650, 1, 3650)
160
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
161
+ try {
162
+ const args = ['learning-to-review', `--limit=${limit}`, ...(days ? [`--days=${days}`] : [])]
163
+ sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeLearningEvents(value, limit, LEARNING_REVIEW_LIMIT))
164
+ } catch (error) {
165
+ console.warn('[context] learning review bridge failure:', (error as Error).message)
166
+ res.status(503).json({ error: 'learning_unavailable' })
167
+ }
168
+ })
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)
147
186
  res.status(503).json({ error: 'learning_unavailable' })
148
187
  }
149
188
  })
@@ -153,8 +192,9 @@ memoryRouter.get('/context/learning/:id', async (req, res) => {
153
192
  if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
154
193
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
155
194
  try {
156
- sendBridgeAnswer(res, await callPython(['learning-event', '--id', req.params.id], 8_000), normalizeLearningEventDetail)
157
- } catch {
195
+ sendBridgeAnswer(res, await callPython(['learning-event', `--id=${req.params.id}`], 8_000), normalizeLearningEventDetail)
196
+ } catch (error) {
197
+ console.warn('[context] learning bridge failure:', (error as Error).message)
158
198
  res.status(503).json({ error: 'learning_unavailable' })
159
199
  }
160
200
  })
@@ -166,7 +206,8 @@ memoryRouter.get('/context/graph/status', async (_req, res) => {
166
206
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
167
207
  try {
168
208
  sendBridgeAnswer(res, await callPython(['graph-status'], 8_000), normalizeGraphStatus)
169
- } catch {
209
+ } catch (error) {
210
+ console.warn('[context] graph bridge failure:', (error as Error).message)
170
211
  res.status(503).json({ error: 'graph_unavailable' })
171
212
  }
172
213
  })
@@ -182,11 +223,14 @@ memoryRouter.get('/context/graph/search', async (req, res) => {
182
223
  const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
183
224
  const type = typeof req.query.type === 'string' ? req.query.type.replace(/[^A-Za-z0-9_ -]/g, '').slice(0, 40) : ''
184
225
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
185
- const args = ['graph-search', '--q', query, '--limit', String(limit), '--offset', String(offset)]
186
- if (type) args.push('--type', type)
226
+ // One token per value (`--q=...`): a value beginning with `-` is then never
227
+ // read by argparse as a flag (QA 2026-09-06).
228
+ const args = ['graph-search', `--q=${query}`, `--limit=${limit}`, `--offset=${offset}`]
229
+ if (type) args.push(`--type=${type}`)
187
230
  try {
188
231
  sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeGraphSearch(value, limit))
189
- } catch {
232
+ } catch (error) {
233
+ console.warn('[context] graph bridge failure:', (error as Error).message)
190
234
  res.status(503).json({ error: 'graph_unavailable' })
191
235
  }
192
236
  })
@@ -202,9 +246,16 @@ memoryRouter.get('/context/graph/entity', async (req, res) => {
202
246
  const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
203
247
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
204
248
  try {
205
- sendBridgeAnswer(res, await callPython(['graph-entity', '--id', id, '--offset', String(offset), '--limit', String(limit)], 8_000),
206
- value => normalizeGraphEntity(value))
207
- } catch {
249
+ const answer = await callPython(['graph-entity', `--id=${id}`, `--offset=${offset}`, `--limit=${limit}`], 8_000)
250
+ // A no-index or unknown-entity answer is a PAYLOAD from the bridge (found: false);
251
+ // it becomes a 404 with the class Control renders, never a 503 (QA 2026-09-06).
252
+ if (bridgePayload(answer) && answer.found !== true) {
253
+ res.status(404).json({ error: answer.index_state === 'missing' ? 'index_missing' : 'entity_not_found' })
254
+ return
255
+ }
256
+ sendBridgeAnswer(res, answer, value => normalizeGraphEntity(value))
257
+ } catch (error) {
258
+ console.warn('[context] graph bridge failure:', (error as Error).message)
208
259
  res.status(503).json({ error: 'graph_unavailable' })
209
260
  }
210
261
  })
@@ -222,11 +273,12 @@ memoryRouter.get('/context/graph/passages', async (req, res) => {
222
273
  const limit = boundedInteger(req.query.limit, 5, 1, 5)
223
274
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
224
275
  const args = entity
225
- ? ['graph-passages', '--entity', entity, '--limit', String(limit)]
226
- : ['graph-passages', '--relation-a', relationA, '--relation-b', relationB, '--limit', String(limit)]
276
+ ? ['graph-passages', `--entity=${entity}`, `--limit=${limit}`]
277
+ : ['graph-passages', `--relation-a=${relationA}`, `--relation-b=${relationB}`, `--limit=${limit}`]
227
278
  try {
228
279
  sendBridgeAnswer(res, await callPython(args, 8_000), normalizeGraphPassages)
229
- } catch {
280
+ } catch (error) {
281
+ console.warn('[context] graph bridge failure:', (error as Error).message)
230
282
  res.status(503).json({ error: 'graph_unavailable' })
231
283
  }
232
284
  })
@@ -245,7 +297,8 @@ memoryRouter.post('/context/graph/index', async (_req, res) => {
245
297
  const code = bridgeErrorCode(data)
246
298
  if (code) { res.status(503).json({ error: code }); return }
247
299
  res.status(202).json(normalizeIndexBuildKickoff(data))
248
- } catch {
300
+ } catch (error) {
301
+ console.warn('[context] graph bridge failure:', (error as Error).message)
249
302
  res.status(503).json({ error: 'graph_unavailable' })
250
303
  }
251
304
  })