@gotcos/glasses-server 6.45.0 → 6.45.1

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,11 @@
1
+ ## 6.45.1
2
+
3
+ Knowledge opens with a bounded graph and supports verified question plans for COS Control 0.5.208.
4
+
5
+ - Expose the initial overview through the authenticated workspace route. Model-assisted graph answers may carry validated anchors, waypoints and traversal results from the configured advanced pipeline. Plans navigate only; they cannot save or activate knowledge.
6
+ - Add paired owner-name setup and editing with conflict detection, durable private profile writes and preservation of existing vocabulary and settings. Display identity does not transfer journal or indexing authority.
7
+ - Report whether retained captures have a saveable transcript, unfinished transcription, or completed audio with no usable speech. Empty session shells are not advertised as recoverable recordings. Audio and late-upload admission retain their existing protections.
8
+
1
9
  ## 6.45.0
2
10
 
3
11
  Versioned local memory and explicit session identity for COS Control 0.5.207.
@@ -3,5 +3,5 @@
3
3
  "protocol": 1,
4
4
  "version": "0.1.0",
5
5
  "file": "cos-memory-runtime-0.1.0.tar.gz",
6
- "sha256": "1f03e8d3bcc878709bae7580a8c685c1c5494839cebd7f2c79248dbc17e4d512"
6
+ "sha256": "5dfe8454f3bad21b0bfd38aafd567990337a7cc58c2bcdc1b2dbb84f8af0ae13"
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.45.0",
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",
3
+ "version": "6.45.1",
4
+ "description": "COS Glasses 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": {
7
7
  "glasses-server": "bin/cli.cjs",
@@ -990,11 +990,19 @@ export function normalizeSampleKickoff(value: unknown): Record<string, unknown>
990
990
  }
991
991
 
992
992
  /** `graph-ask`: one answer from the graph, bounded. */
993
- export function normalizeGraphAnswer(value: unknown): { question: string; mode: string; answer: string; elapsed_s: number | null } | null {
993
+ export function normalizeGraphAnswer(value: unknown): { question: string; mode: string; answer: string; elapsed_s: number | null; investigation?: Record<string, unknown> } | null {
994
994
  const s = asRecord(value) ?? {}
995
995
  const answer = typeof s.answer === 'string' ? s.answer.slice(0, 20_000) : null
996
996
  if (answer === null) return null
997
- return { question: stringOrAbsent(s.question, KNOWLEDGE_ASK_MAX_CHARS) ?? '', mode: stringOrAbsent(s.mode, 16) ?? 'hybrid', answer, elapsed_s: typeof s.elapsed_s === 'number' && Number.isFinite(s.elapsed_s) ? s.elapsed_s : null }
997
+ const candidate = asRecord(s.investigation)
998
+ let investigation: Record<string, unknown> | undefined
999
+ if (candidate?.status === 'unavailable') investigation = { status: 'unavailable', message: stringOrAbsent(candidate.message, 500) ?? 'No verified graph plan is available.' }
1000
+ if (candidate?.status === 'ready' && Array.isArray(candidate.nodes) && candidate.nodes.length <= 200 && Array.isArray(candidate.links) && candidate.links.length <= 500 && JSON.stringify(candidate).length <= 500_000) {
1001
+ // Python validates this read-only plan against the policy-filtered graph and generation.
1002
+ // Forward only the canvas contract; it never becomes an arbitrary workspace request.
1003
+ investigation = Object.fromEntries(['status','message','nodes','links','anchors','waypoints','filters','paths','generation','truncated','truncation_reason','scope'].filter(key => candidate[key] !== undefined).map(key => [key, candidate[key]]))
1004
+ }
1005
+ return { question: stringOrAbsent(s.question, KNOWLEDGE_ASK_MAX_CHARS) ?? '', mode: stringOrAbsent(s.mode, 16) ?? 'hybrid', answer, elapsed_s: typeof s.elapsed_s === 'number' && Number.isFinite(s.elapsed_s) ? s.elapsed_s : null, ...(investigation ? { investigation } : {}) }
998
1006
  }
999
1007
 
1000
1008
  export interface IngestProgressItem { id: string; outcome: 'indexed' | 'failed' | 'unknown'; seconds: number | null; reason: string | null }
@@ -1,14 +1,14 @@
1
1
  // Profile loader — reads user identity from .cos-profile.json (gitignored)
2
2
  // Falls back to generic defaults for users who haven't configured a profile
3
3
 
4
- import { existsSync, readFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
5
5
  import { homedir } from 'node:os'
6
- import { resolve } from 'node:path'
6
+ import { resolve, dirname } from 'node:path'
7
7
  import { atomicWriteFileSync } from './atomic-fs.js'
8
8
 
9
9
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
10
 
11
- const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user'])
11
+ const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user', 'me', 'owner', 'wearer'])
12
12
  const PLACEHOLDER_VOCABULARY = new Set(['nameone', 'nametwo', 'yourcompany', 'productname'])
13
13
  const PLACEHOLDER_CORRECTIONS = new Set(['soundalike\u0000yourname'])
14
14
 
@@ -53,8 +53,9 @@ export function loadProfileObject(): Record<string, unknown> {
53
53
  function loadProfile(): Record<string, unknown> {
54
54
  if (profileCache) return profileCache
55
55
  try {
56
- profileCache = JSON.parse(readFileSync(profilePath(), 'utf-8'))
57
- return profileCache!
56
+ const parsed: unknown = JSON.parse(readFileSync(profilePath(), 'utf-8'))
57
+ profileCache = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}
58
+ return profileCache
58
59
  } catch {
59
60
  profileCache = {}
60
61
  return profileCache
@@ -98,6 +99,29 @@ export function getOwnerName(): string {
98
99
  return !value || PLACEHOLDER_OWNER_NAMES.has(value.toLowerCase()) ? 'User' : value
99
100
  }
100
101
 
102
+ /** Display identity only. Never reassign journal authority or the ingestion-owner Mac. */
103
+ export function setProfileOwnerName(name: unknown, expected: unknown): string {
104
+ if (typeof name !== 'string' || !name.trim() || name.trim().length > 120 || /[\x00-\x1f\x7f]/.test(name) || PLACEHOLDER_OWNER_NAMES.has(name.trim().toLowerCase())) throw new Error('invalid_owner_name')
105
+ if (expected !== null && typeof expected !== 'string') throw new Error('invalid_expected_owner')
106
+ let current: Record<string, unknown> = {}
107
+ const source = profilePath()
108
+ try {
109
+ const parsed: unknown = JSON.parse(readFileSync(source, 'utf8'))
110
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid_profile')
111
+ current = parsed as Record<string, unknown>
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new Error('profile_unreadable')
114
+ }
115
+ const previous = typeof current.owner_name === 'string' ? current.owner_name.trim() : ''
116
+ const actual = !previous || PLACEHOLDER_OWNER_NAMES.has(previous.toLowerCase()) ? null : previous
117
+ if (actual !== expected) throw new Error('owner_changed')
118
+ const target = process.env.COS_PROFILE_PATH?.trim() ? source : homeProfilePath()
119
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
120
+ atomicWriteFileSync(target, JSON.stringify({ ...current, owner_name: name.trim() }, null, 2), { mode: 0o600 })
121
+ clearProfileCache()
122
+ return name.trim()
123
+ }
124
+
101
125
  /** Short speaker label for the glasses wearer, used by diarization to fast-path
102
126
  * the owner's voiceprint. Defaults to 'Me'. Configure via owner_speaker_label. */
103
127
  export function getOwnerSpeakerLabel(): string {
@@ -329,6 +329,8 @@ healthRouter.get('/health', async (_req, res) => {
329
329
  idleMinutes: item.idleMinutes,
330
330
  capturedMinutes: item.capturedMinutes,
331
331
  chunks: item.chunks,
332
+ transcriptState: item.transcriptState,
333
+ canSave: item.canSave,
332
334
  promotesAt: item.promotesAt,
333
335
  hasDraft: item.draftPath != null,
334
336
  })),
@@ -1,6 +1,9 @@
1
1
  import { Router } from 'express'
2
2
  import { callPython, contextSourceAvailable, pythonBridgeState } from '../lib/python-bridge.js'
3
3
  import { searchMemories } from '../lib/context-library-search.js'
4
+ import { getOwnerName, setProfileOwnerName } from '../lib/profile.js'
5
+ import { resetDecoderCaches } from '../lib/whisper-local.js'
6
+ import { resetVocabEchoCache } from '../lib/hallucination-filter.js'
4
7
 
5
8
  /**
6
9
  * Is there anything to serve — a Python bridge OR plain files on disk?
@@ -54,6 +57,24 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
54
57
  } from '../lib/cos-context-browser.js'
55
58
 
56
59
  export const memoryRouter = Router()
60
+ // Profile setup is available before a memory bridge is configured. API authentication still applies.
61
+ memoryRouter.get('/context/profile/owner', (_req, res) => {
62
+ const name = getOwnerName()
63
+ res.json({ owner_name: name === 'User' ? null : name })
64
+ })
65
+ memoryRouter.post('/context/profile/owner', (req, res) => {
66
+ try {
67
+ const body = req.body as Record<string, unknown>
68
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !['owner_name','expected_owner'].includes(key))) { res.status(400).json({ error: 'invalid_owner_profile' }); return }
69
+ const name = setProfileOwnerName(body.owner_name, body.expected_owner)
70
+ resetDecoderCaches(); resetVocabEchoCache()
71
+ res.json({ owner_name: name })
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : ''
74
+ const reason = ['owner_changed','invalid_owner_name','invalid_expected_owner','profile_unreadable'].includes(message) ? message : 'profile_save_failed'
75
+ res.status(reason === 'owner_changed' ? 409 : reason.startsWith('invalid_') ? 400 : 503).json({ error: reason })
76
+ }
77
+ })
57
78
  memoryRouter.use((req, res, next) => {
58
79
  if (['owner', 'audience', 'authority_host', 'authority_epoch'].some(key => key in req.query || (req.body && key in req.body))) {
59
80
  res.status(400).json({ error: 'caller_authority_forbidden' }); return
@@ -62,7 +83,7 @@ memoryRouter.use((req, res, next) => {
62
83
  })
63
84
  // Additive workspace protocol. The existing /memory array contract remains intact.
64
85
  // Authentication is the instance pairing token; caller fields cannot select an owner.
65
- const WORKSPACE_ACTIONS = new Set(['status', 'graph_expand', 'graph_paths', 'graph_resolve', 'list_explorations', 'get_exploration',
86
+ const WORKSPACE_ACTIONS = new Set(['status', 'graph_overview', 'graph_expand', 'graph_paths', 'graph_resolve', 'list_explorations', 'get_exploration',
66
87
  'save_exploration', 'save_assertion', 'policy_get', 'policy_set', 'memory_page', 'learning_page', 'review_page', 'get_memory', 'review_memory', 'trace_summary', 'source_status', 'refresh_source', 'current_sources', 'identity_split_preview', 'identity_status', 'identity_keep_apart', 'rule_page', 'propose_rule', 'review_rule', 'rollback_rule', 'activate_rule', 'rule_evaluation'])
67
88
  memoryRouter.post('/context/memory/workspace', async (req, res) => {
68
89
  noStore(res)
@@ -463,6 +463,8 @@ export interface StrandedCapture {
463
463
  /** Minutes of audio actually captured before it went quiet. */
464
464
  capturedMinutes: number
465
465
  chunks: number
466
+ transcriptState: 'ready' | 'processing' | 'no_speech'
467
+ canSave: boolean
466
468
  /** When the sweeper will save this on the user's behalf. */
467
469
  promotesAt: string
468
470
  /** Readable draft on disk, once the capture has been stale long enough. */
@@ -485,21 +487,30 @@ export interface StrandedCapture {
485
487
  */
486
488
  export function getStrandedCaptures(now = Date.now()): StrandedCapture[] {
487
489
  const drafts = new Map(listStrandedDrafts(STRANDED_DRAFT_DIR).map(d => [d.sessionId, d]))
488
- return getTranscriptionSessionLiveness(now).staleSessions.map(stale => {
490
+ return getTranscriptionSessionLiveness(now).staleSessions.flatMap(stale => {
489
491
  const session = sessions.get(stale.sessionId)
490
492
  const lastActivityAt = session?.lastActivityAt ?? now - stale.silentForMs
491
493
  const draft = drafts.get(stale.sessionId) ?? null
492
- return {
494
+ const received = session?.receivedIndices ?? []
495
+ // An empty session shell is not evidence that audio was lost. Leave its
496
+ // ledger open so a backgrounded phone can still upload before retention.
497
+ if (!stale.chunks && !received.length && !hasSessionAudio(stale.sessionId)) return []
498
+ const completed = new Set(session?.asrCompletedIndices ?? [])
499
+ const noSpeech = !stale.chunks && received.length > 0 && received.every(index => completed.has(index))
500
+ const transcriptState: StrandedCapture['transcriptState'] = stale.chunks ? 'ready' : noSpeech ? 'no_speech' : 'processing'
501
+ return [{
493
502
  sessionId: stale.sessionId,
494
503
  idleMinutes: Math.round(stale.silentForMs / 60_000),
495
504
  capturedMinutes: Math.round(
496
505
  Math.max(0, lastActivityAt - (session?.startTime ?? lastActivityAt)) / 60_000,
497
506
  ),
498
507
  chunks: stale.chunks,
508
+ transcriptState,
509
+ canSave: transcriptState === 'ready',
499
510
  promotesAt: new Date(lastActivityAt + LOCAL_FIRST_MEETING_IDLE_RETENTION_MS).toISOString(),
500
511
  draftPath: draft?.path ?? null,
501
512
  draftBytes: draft?.bytes ?? null,
502
- }
513
+ }]
503
514
  })
504
515
  }
505
516