@gotcos/glasses-server 6.21.33 → 6.21.34

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,19 @@
1
+ ## 6.21.34
2
+
3
+ - **Memory and Threads are now real production surfaces.** Authenticated
4
+ `/api/memory` and `/api/threads` list/detail routes expose bounded read-only
5
+ projections from the configured COS pipeline instead of returning 404.
6
+ - **Stable references, not storage internals.** Memory uses its logical
7
+ `mem_...` ID and Threads use their existing stable ID. Responses never expose
8
+ embeddings, Qdrant point IDs, raw cache files, secrets, or local paths.
9
+ - **Memory overview is complete.** The store total and type split scan the full
10
+ collection instead of silently stopping after the first 1,000 records.
11
+ - **Manual threads remain visible immediately.** The bridge merges the computed
12
+ thread cache with the durable manual-thread store without mutating either.
13
+ - **Standalone installs fail honestly.** Systems without a COS scripts pipeline
14
+ return empty/unavailable shapes while the rest of the glasses server remains
15
+ usable.
16
+
1
17
  ## 6.21.33
2
18
 
3
19
  - **Existing meeting libraries can be selected directly.** `COS_MEETINGS_ROOT`
package/README.md CHANGED
@@ -301,6 +301,15 @@ direct library and an operations root are both configured, the server merges
301
301
  them with standalone G2 recordings and prefers the enriched writable record
302
302
  for the same session.
303
303
 
304
+ Server 6.21.34 adds authenticated, read-only Memory and Threads browsing for
305
+ full COS installs. With `COS_SCRIPTS_DIR` configured, the companion can show the
306
+ complete Bot Memory count/type split, bounded recent summaries, exact logical
307
+ memory IDs, and existing tracked/manual threads. Exact detail requests are
308
+ resolved by stable ID so a spoken follow-up can carry the selected snapshot as
309
+ context. Embeddings, vector-store point IDs, cache files, secrets, and local
310
+ paths never cross the API boundary. Standalone installs report the feature as
311
+ unavailable without affecting messages, meetings, transcription, or agents.
312
+
304
313
  The first server start downloads the real-time turbo model. True HQ additionally
305
314
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
306
315
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.33",
3
+ "version": "6.21.34",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -39,6 +39,8 @@ import { promptEditRouter } from './routes/prompt-edit.js'
39
39
  import { bookmarksRouter } from './routes/bookmarks.js'
40
40
  import { welcomeContextRouter } from './routes/welcome-context.js'
41
41
  import { liveCuesRouter } from './routes/live-cues.js'
42
+ import { memoryRouter } from './routes/memory.js'
43
+ import { threadsRouter } from './routes/threads.js'
42
44
  import { shutdownLiveCues } from './lib/live-cues-engine.js'
43
45
  import { prewarmContext } from './lib/context-builder.js'
44
46
  import { preWarmCLI } from './lib/claude-bridge.js'
@@ -233,6 +235,8 @@ app.use('/api', displayRouter)
233
235
  app.use('/api', transcribeStreamRouter)
234
236
  app.use('/api', meetingRouter)
235
237
  app.use('/api', meetingsRouter)
238
+ app.use('/api', memoryRouter)
239
+ app.use('/api', threadsRouter)
236
240
  app.use('/api', openaiKeyRouter)
237
241
  // v6.3.0 — Message History, cross-day 'reference message N', and history
238
242
  // recovery for public npx users (previously full-COS-server only).
@@ -0,0 +1,230 @@
1
+ export const MEMORY_ID_PATTERN = /^mem_[A-Za-z0-9_:-]{1,120}$/
2
+ export const THREAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
3
+
4
+ const CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
5
+ const ABSOLUTE_PATH = /(^|[\s("'`])(?:~\/|\/(?!\/)(?:[^/\s)"'`]+\/)+[^/\s)"'`]+\/?)/g
6
+ const SECRET_TOKEN = /\b(?:sk-[A-Za-z0-9_-]{12,}|(?:bearer|token|api[_ -]?key)\s*[:=]\s*[A-Za-z0-9._-]{12,})\b/gi
7
+
8
+ export interface MemoryRefGroups {
9
+ people?: string[]
10
+ files?: string[]
11
+ meetings?: string[]
12
+ threads?: string[]
13
+ }
14
+
15
+ export interface MemoryListItem {
16
+ id: string
17
+ type: string
18
+ summary: string
19
+ content: string
20
+ created_at: string
21
+ domain: string
22
+ refs: MemoryRefGroups
23
+ reference_available: true
24
+ }
25
+
26
+ export interface ThreadListItem {
27
+ id: string
28
+ name: string
29
+ domain: string
30
+ is_manual: boolean
31
+ topics: string[]
32
+ meeting_count: number
33
+ first_seen: string
34
+ last_seen: string
35
+ velocity: string
36
+ age_days: number
37
+ is_stale: boolean
38
+ is_resolved: boolean
39
+ meetings: Array<{ name: string; date: string }>
40
+ manual_updates: Array<{ content: string; timestamp: string; source: string }>
41
+ stakeholders: string[]
42
+ milestones: string[]
43
+ sources: string[]
44
+ target_date: string
45
+ serves_goal: string
46
+ created_at: string
47
+ created_by: string
48
+ access_count: number
49
+ reference_available: true
50
+ }
51
+
52
+ export function cleanContextText(value: unknown, limit: number): string {
53
+ let text = typeof value === 'string' ? value : value == null ? '' : String(value)
54
+ text = text.replace(CONTROL_CHARS, '')
55
+ text = text.replace(ABSOLUTE_PATH, (_match, prefix: string) => `${prefix}[local path hidden]`)
56
+ text = text.replace(SECRET_TOKEN, '[secret hidden]')
57
+ return text.trim().slice(0, limit)
58
+ }
59
+
60
+ function finiteInteger(value: unknown, fallback = 0, maximum = Number.MAX_SAFE_INTEGER): number {
61
+ const number = typeof value === 'number' ? value : Number(value)
62
+ if (!Number.isFinite(number)) return fallback
63
+ return Math.max(0, Math.min(maximum, Math.trunc(number)))
64
+ }
65
+
66
+ function stringList(value: unknown, itemLimit: number, textLimit: number): string[] {
67
+ if (!Array.isArray(value)) return []
68
+ return value.slice(0, itemLimit).map(item => {
69
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
70
+ const record = item as Record<string, unknown>
71
+ const label = record.title ?? record.name ?? record.summary ?? record.content ?? record.path ?? record.url
72
+ return cleanContextText(label ?? '', textLimit)
73
+ }
74
+ return cleanContextText(item, textLimit)
75
+ }).filter(Boolean)
76
+ }
77
+
78
+ function memoryRefs(value: unknown): MemoryRefGroups {
79
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
80
+ const source = value as Record<string, unknown>
81
+ const result: MemoryRefGroups = {}
82
+ for (const key of ['people', 'files', 'meetings', 'threads'] as const) {
83
+ const items = stringList(source[key], 12, 160)
84
+ if (items.length) result[key] = items
85
+ }
86
+ return result
87
+ }
88
+
89
+ export function normalizeMemoryList(value: unknown, limit: number): MemoryListItem[] {
90
+ if (!Array.isArray(value)) return []
91
+ const result: MemoryListItem[] = []
92
+ for (const row of value.slice(0, Math.max(0, Math.min(limit, 50)))) {
93
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue
94
+ const source = row as Record<string, unknown>
95
+ const id = cleanContextText(source.id, 128)
96
+ if (!MEMORY_ID_PATTERN.test(id)) continue
97
+ result.push({
98
+ id,
99
+ type: cleanContextText(source.type || 'unknown', 48),
100
+ summary: cleanContextText(source.summary || source.content, 240),
101
+ content: cleanContextText(source.content, 1200),
102
+ created_at: cleanContextText(source.created_at, 64),
103
+ domain: cleanContextText(source.domain, 64),
104
+ refs: memoryRefs(source.refs),
105
+ reference_available: true,
106
+ })
107
+ }
108
+ return result
109
+ }
110
+
111
+ export function normalizeMemoryDetail(value: unknown): (MemoryListItem & {
112
+ auto_generated: boolean
113
+ consolidated: boolean
114
+ }) | null {
115
+ const rows = normalizeMemoryList(value && typeof value === 'object' ? [value] : [], 1)
116
+ if (!rows[0] || !value || typeof value !== 'object' || Array.isArray(value)) return null
117
+ const source = value as Record<string, unknown>
118
+ return {
119
+ ...rows[0],
120
+ content: cleanContextText(source.content, 32_000),
121
+ auto_generated: source.auto_generated === true,
122
+ consolidated: source.consolidated === true,
123
+ }
124
+ }
125
+
126
+ function normalizeThread(value: unknown, detail: boolean): ThreadListItem | null {
127
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
128
+ const source = value as Record<string, unknown>
129
+ const id = cleanContextText(source.id, 128)
130
+ if (!THREAD_ID_PATTERN.test(id)) return null
131
+ const meetings = Array.isArray(source.meetings)
132
+ ? source.meetings.slice(0, detail ? 50 : 12).flatMap(item => {
133
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return []
134
+ const meeting = item as Record<string, unknown>
135
+ return [{ name: cleanContextText(meeting.name, 240), date: cleanContextText(meeting.date, 32) }]
136
+ })
137
+ : []
138
+ const manualUpdates = Array.isArray(source.manual_updates)
139
+ ? source.manual_updates.slice(0, detail ? 20 : 8).flatMap(item => {
140
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return []
141
+ const update = item as Record<string, unknown>
142
+ return [{
143
+ content: cleanContextText(update.content, detail ? 2000 : 500),
144
+ timestamp: cleanContextText(update.timestamp, 64),
145
+ source: cleanContextText(update.source, 120),
146
+ }]
147
+ })
148
+ : []
149
+ return {
150
+ id,
151
+ name: cleanContextText(source.name || 'Untitled thread', 160),
152
+ domain: cleanContextText(source.domain || 'unknown', 64),
153
+ is_manual: source.is_manual === true,
154
+ topics: stringList(source.topics, detail ? 40 : 12, 160),
155
+ meeting_count: finiteInteger(source.meeting_count, meetings.length, 100_000),
156
+ first_seen: cleanContextText(source.first_seen, 32),
157
+ last_seen: cleanContextText(source.last_seen, 32),
158
+ velocity: cleanContextText(source.velocity, 48),
159
+ age_days: finiteInteger(source.age_days, 0, 1_000_000),
160
+ is_stale: source.is_stale === true,
161
+ is_resolved: source.is_resolved === true,
162
+ meetings,
163
+ manual_updates: manualUpdates,
164
+ stakeholders: stringList(source.stakeholders, detail ? 30 : 12, 160),
165
+ milestones: stringList(source.milestones, detail ? 30 : 8, 500),
166
+ sources: stringList(source.sources, detail ? 30 : 8, 500),
167
+ target_date: cleanContextText(source.target_date, 32),
168
+ serves_goal: cleanContextText(source.serves_goal, 160),
169
+ created_at: cleanContextText(source.created_at, 64),
170
+ created_by: cleanContextText(source.created_by, 120),
171
+ access_count: finiteInteger(source.access_count),
172
+ reference_available: true,
173
+ }
174
+ }
175
+
176
+ export function normalizeThreads(value: unknown, limit: number): {
177
+ generated_at: string
178
+ active_count: number
179
+ stale_count: number
180
+ resolved_count: number
181
+ threads: ThreadListItem[]
182
+ } {
183
+ const source = value && typeof value === 'object' && !Array.isArray(value)
184
+ ? value as Record<string, unknown>
185
+ : {}
186
+ const rawThreads = Array.isArray(source.threads) ? source.threads : []
187
+ const threads = rawThreads.slice(0, Math.max(0, Math.min(limit, 50)))
188
+ .map(item => normalizeThread(item, false))
189
+ .filter((item): item is ThreadListItem => item !== null)
190
+ return {
191
+ generated_at: cleanContextText(source.generated_at, 64),
192
+ active_count: finiteInteger(source.active_count),
193
+ stale_count: finiteInteger(source.stale_count),
194
+ resolved_count: finiteInteger(source.resolved_count),
195
+ threads,
196
+ }
197
+ }
198
+
199
+ export function normalizeThreadDetail(value: unknown): ThreadListItem | null {
200
+ return normalizeThread(value, true)
201
+ }
202
+
203
+ export function normalizeMemoryOverview(value: unknown): {
204
+ available: boolean
205
+ collection: 'cos_memory'
206
+ total: number
207
+ by_type: Record<string, number>
208
+ reason?: string
209
+ } {
210
+ const source = value && typeof value === 'object' && !Array.isArray(value)
211
+ ? value as Record<string, unknown>
212
+ : {}
213
+ const rawTypes = source.by_type && typeof source.by_type === 'object' && !Array.isArray(source.by_type)
214
+ ? source.by_type as Record<string, unknown>
215
+ : {}
216
+ const byType: Record<string, number> = {}
217
+ for (const [rawKey, rawValue] of Object.entries(rawTypes).slice(0, 24)) {
218
+ const key = cleanContextText(rawKey, 48)
219
+ if (key) byType[key] = finiteInteger(rawValue)
220
+ }
221
+ const result: ReturnType<typeof normalizeMemoryOverview> = {
222
+ available: source.available === true,
223
+ collection: 'cos_memory',
224
+ total: finiteInteger(source.total),
225
+ by_type: byType,
226
+ }
227
+ const reason = cleanContextText(source.reason, 120)
228
+ if (reason) result.reason = reason
229
+ return result
230
+ }
@@ -31,6 +31,10 @@ const BRIDGE_SCRIPT: string | null = COS_SCRIPTS_DIR ? resolve(COS_SCRIPTS_DIR,
31
31
  // have these, so callPython() degrades to a no-op.
32
32
  const pythonAvailable = !!(COS_SCRIPTS_DIR && existsSync(PYTHON_BIN!) && existsSync(BRIDGE_SCRIPT!))
33
33
 
34
+ export function pythonBridgeAvailable(): boolean {
35
+ return pythonAvailable
36
+ }
37
+
34
38
  if (pythonAvailable) {
35
39
  console.log('[python-bridge] COS pipeline detected — sourcing live context')
36
40
  } else if (COS_SCRIPTS_DIR) {
@@ -54,8 +58,17 @@ function standaloneNoop(args: string[]): unknown {
54
58
  switch (args[0]) {
55
59
  case 'calendar': return { events: [] }
56
60
  case 'tasks': return {}
57
- case 'threads': return []
61
+ case 'threads': return { threads: [], active_count: 0, stale_count: 0, resolved_count: 0 }
62
+ case 'thread-detail': return { error: 'cos_pipeline_not_configured' }
58
63
  case 'memory': return []
64
+ case 'memory-overview': return {
65
+ available: false,
66
+ collection: 'cos_memory',
67
+ total: 0,
68
+ by_type: {},
69
+ reason: 'cos_pipeline_not_configured',
70
+ }
71
+ case 'memory-detail': return { error: 'cos_pipeline_not_configured' }
59
72
  case 'badges': return {}
60
73
  default: return {}
61
74
  }
@@ -0,0 +1,65 @@
1
+ import { Router } from 'express'
2
+ import { callPython } from '../lib/python-bridge.js'
3
+ import {
4
+ MEMORY_ID_PATTERN,
5
+ normalizeMemoryDetail,
6
+ normalizeMemoryList,
7
+ normalizeMemoryOverview,
8
+ } from '../lib/cos-context-browser.js'
9
+
10
+ export const memoryRouter = Router()
11
+
12
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
13
+ const parsed = Number(value)
14
+ if (!Number.isFinite(parsed)) return fallback
15
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
16
+ }
17
+
18
+ memoryRouter.get('/memory/overview', async (_req, res) => {
19
+ try {
20
+ const data = await callPython(['memory-overview'])
21
+ res.json(normalizeMemoryOverview(data))
22
+ } catch (error) {
23
+ res.status(503).json({
24
+ available: false,
25
+ collection: 'cos_memory',
26
+ total: 0,
27
+ by_type: {},
28
+ reason: 'memory_bridge_unavailable',
29
+ })
30
+ }
31
+ })
32
+
33
+ memoryRouter.get('/memory/:id', async (req, res) => {
34
+ if (!MEMORY_ID_PATTERN.test(req.params.id)) {
35
+ res.status(400).json({ error: 'invalid_memory_id' })
36
+ return
37
+ }
38
+ try {
39
+ const data = await callPython(['memory-detail', req.params.id])
40
+ if (data && typeof data === 'object' && 'error' in data) {
41
+ res.status(404).json({ error: 'memory_not_found' })
42
+ return
43
+ }
44
+ const memory = normalizeMemoryDetail(data)
45
+ if (!memory) {
46
+ res.status(404).json({ error: 'memory_not_found' })
47
+ return
48
+ }
49
+ res.json(memory)
50
+ } catch {
51
+ res.status(503).json({ error: 'memory_unavailable' })
52
+ }
53
+ })
54
+
55
+ memoryRouter.get('/memory', async (req, res) => {
56
+ const days = boundedInteger(req.query.days, 30, 1, 3650)
57
+ const limit = boundedInteger(req.query.limit, 20, 1, 50)
58
+ try {
59
+ const data = await callPython(['memory', '--days', String(days), '--limit', String(limit)])
60
+ // Preserve the legacy top-level array used by released companions.
61
+ res.json(normalizeMemoryList(data, limit))
62
+ } catch {
63
+ res.json([])
64
+ }
65
+ })
@@ -0,0 +1,38 @@
1
+ import { Router } from 'express'
2
+ import { callPython } from '../lib/python-bridge.js'
3
+ import { THREAD_ID_PATTERN, normalizeThreadDetail, normalizeThreads } from '../lib/cos-context-browser.js'
4
+
5
+ export const threadsRouter = Router()
6
+
7
+ threadsRouter.get('/threads/:id', async (req, res) => {
8
+ if (!THREAD_ID_PATTERN.test(req.params.id)) {
9
+ res.status(400).json({ error: 'invalid_thread_id' })
10
+ return
11
+ }
12
+ try {
13
+ const data = await callPython(['thread-detail', req.params.id])
14
+ if (data && typeof data === 'object' && 'error' in data) {
15
+ res.status(404).json({ error: 'thread_not_found' })
16
+ return
17
+ }
18
+ const thread = normalizeThreadDetail(data)
19
+ if (!thread) {
20
+ res.status(404).json({ error: 'thread_not_found' })
21
+ return
22
+ }
23
+ res.json(thread)
24
+ } catch {
25
+ res.status(503).json({ error: 'threads_unavailable' })
26
+ }
27
+ })
28
+
29
+ threadsRouter.get('/threads', async (req, res) => {
30
+ const parsed = Number(req.query.limit)
31
+ const limit = Number.isFinite(parsed) ? Math.max(1, Math.min(50, Math.trunc(parsed))) : 30
32
+ try {
33
+ const data = await callPython(['threads'])
34
+ res.json(normalizeThreads(data, limit))
35
+ } catch {
36
+ res.json(normalizeThreads({}, limit))
37
+ }
38
+ })