@gotcos/glasses-server 6.44.5 → 6.44.6

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,28 @@
1
+ ## 6.44.6
2
+
3
+ What /qa found in 6.44.5 before anyone installed it.
4
+
5
+ - `GET /api/context/learning/review`: the strict To review set (the bridge's
6
+ `learning-to-review`, the same set the status block counts) as event rows,
7
+ one page of up to 200. 6.44.5's list approximated it with a kind filter and
8
+ showed "50 of 837" beside a chip that said 121.
9
+ - Every user value now rides as one argv token (`--q=...`, `--id=...`,
10
+ `--entity=...`), so a search term or entity name that begins with `-` can no
11
+ longer be read by the bridge's argument parser as a flag.
12
+ - An older COS bridge prints its unknown-command answer to stdout; the server
13
+ probed only stderr, so every learning route on such a bridge answered 503
14
+ instead of `cos_pipeline_not_configured`. Both are probed now.
15
+ - A `found: false` entity answer is a 404 with a class Control can name,
16
+ `index_missing` or `entity_not_found`, never a 503; the null-normalizer
17
+ fallback says `record_not_found`.
18
+ - `/api/context/status` answers `Cache-Control: private, no-store` like the
19
+ eight routes that shipped with it; it carries private counts.
20
+ - Every learning and graph route logs the bridge failure it used to swallow,
21
+ and the search shape passes the bridge's own `scope` through.
22
+ - On the COS side (same night): a JSON error exits 0 so it is parsed rather
23
+ than rejected, the bot-memory reach is bounded at 2.5 s so a wedged Qdrant
24
+ cannot blank the file stores, and entity descriptions cap at 12.
25
+
1
26
  ## 6.44.5
2
27
 
3
28
  Recent learning and the knowledge graph, read-only, for COS Control.
@@ -8,7 +33,7 @@ Recent learning and the knowledge graph, read-only, for COS Control.
8
33
  the command, times out, or answers `{ error }` leaves `memory` and `threads`
9
34
  exactly as they were and the two blocks absent. Nothing an existing client
10
35
  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
36
+ - Seven read routes: `GET /api/context/learning` (cursor-paged events with a
12
37
  per-store coverage map), `GET /api/context/learning/status`,
13
38
  `GET /api/context/learning/:id`, `GET /api/context/graph/status`,
14
39
  `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.6",
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,
@@ -55,6 +55,7 @@ export const LEARNING_COMMANDS = [
55
55
  'graph-entity',
56
56
  'graph-passages',
57
57
  'graph-index-build',
58
+ 'learning-to-review',
58
59
  ] as const
59
60
 
60
61
  // The optional Python bridge is available only when the user points us at a real
@@ -226,6 +227,7 @@ function standaloneNoop(args: string[]): unknown {
226
227
  case 'graph-entity':
227
228
  case 'graph-passages':
228
229
  case 'graph-index-build':
230
+ case 'learning-to-review':
229
231
  return { error: 'cos_pipeline_not_configured' }
230
232
  case 'task-rows':
231
233
  case 'task-capture':
@@ -255,7 +257,10 @@ function callPythonDirect(args: string[], timeoutMs: number, input?: string): Pr
255
257
  { cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
256
258
  (err, stdout, stderr) => {
257
259
  if (err) {
258
- const msg = stderr?.trim() || err.message
260
+ // The bridge prints its unknown-command answer to STDOUT and exits 1
261
+ // (cos_api_bridge.py), so an older checkout must be probed there too, or
262
+ // every learning route answers 503 instead of not-configured (QA 2026-09-06).
263
+ const msg = stderr?.trim() || stdout?.trim() || err.message
259
264
  if (typeof msg === 'string' && msg.includes('unknown command')) {
260
265
  return resolvePromise({ error: { code: 'cos_pipeline_not_configured', message: msg } })
261
266
  }
@@ -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 { 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,30 @@ 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)
147
166
  res.status(503).json({ error: 'learning_unavailable' })
148
167
  }
149
168
  })
@@ -153,8 +172,9 @@ memoryRouter.get('/context/learning/:id', async (req, res) => {
153
172
  if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
154
173
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
155
174
  try {
156
- sendBridgeAnswer(res, await callPython(['learning-event', '--id', req.params.id], 8_000), normalizeLearningEventDetail)
157
- } catch {
175
+ sendBridgeAnswer(res, await callPython(['learning-event', `--id=${req.params.id}`], 8_000), normalizeLearningEventDetail)
176
+ } catch (error) {
177
+ console.warn('[context] learning bridge failure:', (error as Error).message)
158
178
  res.status(503).json({ error: 'learning_unavailable' })
159
179
  }
160
180
  })
@@ -166,7 +186,8 @@ memoryRouter.get('/context/graph/status', async (_req, res) => {
166
186
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
167
187
  try {
168
188
  sendBridgeAnswer(res, await callPython(['graph-status'], 8_000), normalizeGraphStatus)
169
- } catch {
189
+ } catch (error) {
190
+ console.warn('[context] graph bridge failure:', (error as Error).message)
170
191
  res.status(503).json({ error: 'graph_unavailable' })
171
192
  }
172
193
  })
@@ -182,11 +203,14 @@ memoryRouter.get('/context/graph/search', async (req, res) => {
182
203
  const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
183
204
  const type = typeof req.query.type === 'string' ? req.query.type.replace(/[^A-Za-z0-9_ -]/g, '').slice(0, 40) : ''
184
205
  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)
206
+ // One token per value (`--q=...`): a value beginning with `-` is then never
207
+ // read by argparse as a flag (QA 2026-09-06).
208
+ const args = ['graph-search', `--q=${query}`, `--limit=${limit}`, `--offset=${offset}`]
209
+ if (type) args.push(`--type=${type}`)
187
210
  try {
188
211
  sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeGraphSearch(value, limit))
189
- } catch {
212
+ } catch (error) {
213
+ console.warn('[context] graph bridge failure:', (error as Error).message)
190
214
  res.status(503).json({ error: 'graph_unavailable' })
191
215
  }
192
216
  })
@@ -202,9 +226,16 @@ memoryRouter.get('/context/graph/entity', async (req, res) => {
202
226
  const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
203
227
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
204
228
  try {
205
- sendBridgeAnswer(res, await callPython(['graph-entity', '--id', id, '--offset', String(offset), '--limit', String(limit)], 8_000),
206
- value => normalizeGraphEntity(value))
207
- } catch {
229
+ const answer = await callPython(['graph-entity', `--id=${id}`, `--offset=${offset}`, `--limit=${limit}`], 8_000)
230
+ // A no-index or unknown-entity answer is a PAYLOAD from the bridge (found: false);
231
+ // it becomes a 404 with the class Control renders, never a 503 (QA 2026-09-06).
232
+ if (bridgePayload(answer) && answer.found !== true) {
233
+ res.status(404).json({ error: answer.index_state === 'missing' ? 'index_missing' : 'entity_not_found' })
234
+ return
235
+ }
236
+ sendBridgeAnswer(res, answer, value => normalizeGraphEntity(value))
237
+ } catch (error) {
238
+ console.warn('[context] graph bridge failure:', (error as Error).message)
208
239
  res.status(503).json({ error: 'graph_unavailable' })
209
240
  }
210
241
  })
@@ -222,11 +253,12 @@ memoryRouter.get('/context/graph/passages', async (req, res) => {
222
253
  const limit = boundedInteger(req.query.limit, 5, 1, 5)
223
254
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
224
255
  const args = entity
225
- ? ['graph-passages', '--entity', entity, '--limit', String(limit)]
226
- : ['graph-passages', '--relation-a', relationA, '--relation-b', relationB, '--limit', String(limit)]
256
+ ? ['graph-passages', `--entity=${entity}`, `--limit=${limit}`]
257
+ : ['graph-passages', `--relation-a=${relationA}`, `--relation-b=${relationB}`, `--limit=${limit}`]
227
258
  try {
228
259
  sendBridgeAnswer(res, await callPython(args, 8_000), normalizeGraphPassages)
229
- } catch {
260
+ } catch (error) {
261
+ console.warn('[context] graph bridge failure:', (error as Error).message)
230
262
  res.status(503).json({ error: 'graph_unavailable' })
231
263
  }
232
264
  })
@@ -245,7 +277,8 @@ memoryRouter.post('/context/graph/index', async (_req, res) => {
245
277
  const code = bridgeErrorCode(data)
246
278
  if (code) { res.status(503).json({ error: code }); return }
247
279
  res.status(202).json(normalizeIndexBuildKickoff(data))
248
- } catch {
280
+ } catch (error) {
281
+ console.warn('[context] graph bridge failure:', (error as Error).message)
249
282
  res.status(503).json({ error: 'graph_unavailable' })
250
283
  }
251
284
  })