@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 +8 -0
- package/memory-runtime/cos-memory-runtime-0.1.0.tar.gz +0 -0
- package/memory-runtime/runtime.json +1 -1
- package/package.json +2 -2
- package/server/lib/cos-context-browser.ts +10 -2
- package/server/lib/profile.ts +29 -5
- package/server/routes/health.ts +2 -0
- package/server/routes/memory.ts +22 -1
- package/server/routes/transcribe-stream.ts +14 -3
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.
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.45.
|
|
4
|
-
"description": "COS Glasses
|
|
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
|
-
|
|
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 }
|
package/server/lib/profile.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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 {
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|
})),
|
package/server/routes/memory.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|