@gotcos/glasses-server 6.16.5 → 6.17.0
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/.env.example +4 -1
- package/CHANGELOG.md +71 -0
- package/README.md +57 -0
- package/package.json +2 -2
- package/server/lib/archive.ts +9 -3
- package/server/lib/claude-bridge.ts +2 -0
- package/server/lib/codex-bridge.ts +2 -0
- package/server/lib/conversation.ts +38 -0
- package/server/lib/cursor-bridge.ts +6 -3
- package/server/lib/message-era.ts +94 -0
- package/server/lib/profile.ts +24 -2
- package/server/lib/prompt-draft-store.ts +39 -3
- package/server/lib/query-job-runtime.ts +36 -6
- package/server/lib/speaker-embeddings.ts +169 -6
- package/server/routes/health.ts +7 -0
- package/server/routes/message-ref.ts +44 -10
- package/server/routes/prompt-drafts.ts +48 -21
- package/server/routes/query.ts +20 -1
- package/server/routes/sessions.ts +23 -6
- package/server/routes/voice.ts +9 -3
- package/server/routes/welcome-context.ts +24 -15
- package/server/scripts/reset-message-era.ts +28 -0
|
@@ -8,18 +8,62 @@
|
|
|
8
8
|
import { resolve } from 'node:path'
|
|
9
9
|
import { errMsg } from './utils.js'
|
|
10
10
|
import { getOwnerSpeakerLabel } from './profile.js'
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
12
14
|
import { fileURLToPath } from 'node:url'
|
|
13
15
|
|
|
16
|
+
/** A real voiceprint model is ~26 MB; anything this small is a bad download. */
|
|
17
|
+
const MIN_MODEL_BYTES = 1_000_000
|
|
18
|
+
const PROBE_TIMEOUT_MS = 30_000
|
|
19
|
+
|
|
14
20
|
// sherpa-onnx-node is CJS — use createRequire for ESM compat
|
|
15
21
|
import { createRequire } from 'node:module'
|
|
16
22
|
const require = createRequire(import.meta.url)
|
|
17
23
|
|
|
18
24
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
19
25
|
|
|
20
|
-
const MODEL_PATH = resolve(__dirname, '..', 'models',
|
|
21
|
-
'3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx')
|
|
22
26
|
import { DATA_DIR } from './data-dir.js'
|
|
27
|
+
|
|
28
|
+
export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
|
|
29
|
+
|
|
30
|
+
/** Where the voiceprint model may live, in priority order:
|
|
31
|
+
*
|
|
32
|
+
* 1. COS_SPEAKER_MODEL_PATH — explicit override (full path to the .onnx).
|
|
33
|
+
* 2. ~/.cos-glasses/models/ — the bolt-on location. The model is ~26 MB and is
|
|
34
|
+
* deliberately NOT in the npm tarball, so a managed install has no bundled
|
|
35
|
+
* copy. The data home survives generation swaps; anything inside the
|
|
36
|
+
* installed package does not, and a model dropped there is destroyed by the
|
|
37
|
+
* next update.
|
|
38
|
+
* 3. server/models/ — bundled, which only exists in a source checkout.
|
|
39
|
+
*
|
|
40
|
+
* Anchored on homedir() rather than DATA_DIR/'..' on purpose: path.resolve is
|
|
41
|
+
* purely lexical, so deriving a sibling of a relocated COS_DATA_DIR could point
|
|
42
|
+
* the "durable" candidate at an unwritable root, or — if COS_DATA_DIR were ever
|
|
43
|
+
* set inside the package — collapse it back onto the very directory an update
|
|
44
|
+
* destroys, silently reintroducing the bug this ordering exists to fix.
|
|
45
|
+
*
|
|
46
|
+
* Diarization is opt-in by design: with no model the system stays on amplitude
|
|
47
|
+
* fallback (wearer vs Ext) rather than failing. speakerModelState() exists so
|
|
48
|
+
* that choice is VISIBLE in /api/health instead of silently degrading.
|
|
49
|
+
*/
|
|
50
|
+
export function speakerModelCandidates(): string[] {
|
|
51
|
+
const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
|
|
52
|
+
return [
|
|
53
|
+
...(override ? [resolve(override)] : []),
|
|
54
|
+
resolve(homedir(), '.cos-glasses', 'models', SPEAKER_MODEL_FILENAME),
|
|
55
|
+
resolve(__dirname, '..', 'models', SPEAKER_MODEL_FILENAME),
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveSpeakerModelPath(): string | null {
|
|
60
|
+
// isFile, not existsSync: a directory passes an existence check and would be
|
|
61
|
+
// handed to the native loader as a model.
|
|
62
|
+
return speakerModelCandidates().find(p => {
|
|
63
|
+
try { return statSync(p).isFile() } catch { return false }
|
|
64
|
+
}) ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
23
67
|
const PROFILES_PATH = resolve(DATA_DIR, 'voice-profiles.json')
|
|
24
68
|
const CALIBRATION_LOG = resolve(DATA_DIR, 'speaker-calibration.jsonl')
|
|
25
69
|
|
|
@@ -58,14 +102,109 @@ interface ProfileStore {
|
|
|
58
102
|
profiles: VoiceProfile[]
|
|
59
103
|
}
|
|
60
104
|
|
|
105
|
+
/** Cheap structural screen for a downloaded model.
|
|
106
|
+
*
|
|
107
|
+
* Catches the common bad downloads — an HTML error/redirect page saved as
|
|
108
|
+
* .onnx, or a truncated transfer — before the file reaches the native runtime.
|
|
109
|
+
* ONNX is protobuf: field 1 (ir_version, varint) encodes as a leading 0x08.
|
|
110
|
+
* This is a screen, not validation; probeModelSafely() is the real gate. */
|
|
111
|
+
function looksLikeOnnxModel(path: string): boolean {
|
|
112
|
+
try {
|
|
113
|
+
if (statSync(path).size < MIN_MODEL_BYTES) return false
|
|
114
|
+
const head = Buffer.alloc(1)
|
|
115
|
+
const fd = openSync(path, 'r')
|
|
116
|
+
try { readSync(fd, head, 0, 1, 0) } finally { closeSync(fd) }
|
|
117
|
+
return head[0] === 0x08
|
|
118
|
+
} catch { return false }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Load the model in a throwaway child process first.
|
|
122
|
+
*
|
|
123
|
+
* onnxruntime does not throw on a malformed or mismatched model — it calls
|
|
124
|
+
* std::terminate, so the process dies with SIGABRT (or SIGSEGV for a valid-but-
|
|
125
|
+
* wrong model). No try/catch can intercept that. In a managed install the
|
|
126
|
+
* LaunchAgent has KeepAlive, so the death becomes a permanent restart loop that
|
|
127
|
+
* takes down queries, meetings, and transcription — the whole server, over an
|
|
128
|
+
* optional feature.
|
|
129
|
+
*
|
|
130
|
+
* Absorbing that crash in a child keeps a bad file a diarization problem
|
|
131
|
+
* instead of an outage. Cost is one short-lived process, once, and only when a
|
|
132
|
+
* model is actually present. */
|
|
133
|
+
function probeModelSafely(modelPath: string): { ok: true } | { ok: false; reason: string } {
|
|
134
|
+
const script =
|
|
135
|
+
"const{SpeakerEmbeddingExtractor}=require('sherpa-onnx-node');" +
|
|
136
|
+
"new SpeakerEmbeddingExtractor({model:process.argv[1],numThreads:1,provider:'cpu'});"
|
|
137
|
+
const probe = spawnSync(process.execPath, ['-e', script, modelPath], {
|
|
138
|
+
cwd: resolve(__dirname, '..', '..'), // package root, so sherpa-onnx-node resolves
|
|
139
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
140
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
141
|
+
})
|
|
142
|
+
if (probe.signal) return { ok: false, reason: `native runtime aborted (${probe.signal})` }
|
|
143
|
+
if (probe.error) return { ok: false, reason: probe.error.message }
|
|
144
|
+
if (probe.status !== 0) {
|
|
145
|
+
const stderr = String(probe.stderr ?? '').trim().split('\n').pop() ?? ''
|
|
146
|
+
return { ok: false, reason: stderr || `probe exited ${probe.status}` }
|
|
147
|
+
}
|
|
148
|
+
return { ok: true }
|
|
149
|
+
}
|
|
150
|
+
|
|
61
151
|
/** Initialize speaker embedding system. Returns false if model missing (graceful degradation). */
|
|
62
152
|
export function initSpeakerEmbeddings(): boolean {
|
|
63
153
|
if (initialized) return extractor !== null
|
|
64
154
|
|
|
65
155
|
initialized = true
|
|
66
156
|
|
|
67
|
-
|
|
68
|
-
|
|
157
|
+
const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
|
|
158
|
+
if (override) {
|
|
159
|
+
const overridePath = resolve(override)
|
|
160
|
+
let usable = false
|
|
161
|
+
try { usable = statSync(overridePath).isFile() } catch { usable = false }
|
|
162
|
+
if (!usable) {
|
|
163
|
+
// Falling through silently would leave the operator believing an override
|
|
164
|
+
// they mistyped (or pointed at a directory) is in effect.
|
|
165
|
+
console.warn(
|
|
166
|
+
`[speaker] COS_SPEAKER_MODEL_PATH=${overridePath} is not a readable file — ignoring it`,
|
|
167
|
+
'and falling back to the remaining candidates.',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (override !== overridePath) {
|
|
171
|
+
// Relative paths resolve against cwd, which under launchd is the installed
|
|
172
|
+
// package — a location the next update deletes.
|
|
173
|
+
console.warn(
|
|
174
|
+
`[speaker] COS_SPEAKER_MODEL_PATH is relative; resolved against the working directory to ${overridePath}.`,
|
|
175
|
+
'Use an absolute path so it cannot move with the process.',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const modelPath = resolveSpeakerModelPath()
|
|
181
|
+
if (!modelPath) {
|
|
182
|
+
const boltOn = resolve(homedir(), '.cos-glasses', 'models')
|
|
183
|
+
console.log(
|
|
184
|
+
'[speaker] Voiceprint model not found — embedding disabled, speaker labels come from the client.',
|
|
185
|
+
`Searched: ${speakerModelCandidates().join(', ')}.`,
|
|
186
|
+
// Name the durable directory outright. An ordinal ("the second path")
|
|
187
|
+
// shifts with the override and pointed users at the package copy, which
|
|
188
|
+
// the next update deletes.
|
|
189
|
+
`To enable diarization put ${SPEAKER_MODEL_FILENAME} in ${boltOn}/ and restart.`,
|
|
190
|
+
)
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!looksLikeOnnxModel(modelPath)) {
|
|
195
|
+
console.error(
|
|
196
|
+
`[speaker] ${modelPath} does not look like an ONNX model (too small, or not protobuf) —`,
|
|
197
|
+
'embedding disabled. A partial download or an HTML error page saved as .onnx does this.',
|
|
198
|
+
)
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const probe = probeModelSafely(modelPath)
|
|
203
|
+
if (!probe.ok) {
|
|
204
|
+
console.error(
|
|
205
|
+
`[speaker] ${modelPath} failed to load — embedding disabled. Reason: ${probe.reason}.`,
|
|
206
|
+
'The server is otherwise unaffected; replace the model file and restart.',
|
|
207
|
+
)
|
|
69
208
|
return false
|
|
70
209
|
}
|
|
71
210
|
|
|
@@ -73,7 +212,7 @@ export function initSpeakerEmbeddings(): boolean {
|
|
|
73
212
|
sherpaOnnx = require('sherpa-onnx-node')
|
|
74
213
|
|
|
75
214
|
extractor = new sherpaOnnx.SpeakerEmbeddingExtractor({
|
|
76
|
-
model:
|
|
215
|
+
model: modelPath,
|
|
77
216
|
numThreads: 2,
|
|
78
217
|
provider: 'cpu',
|
|
79
218
|
})
|
|
@@ -332,6 +471,30 @@ export function isEmbeddingAvailable(): boolean {
|
|
|
332
471
|
return extractor !== null && manager !== null
|
|
333
472
|
}
|
|
334
473
|
|
|
474
|
+
/** Reported on /api/health so an amplitude fallback is never mistaken for real
|
|
475
|
+
* diarization.
|
|
476
|
+
*
|
|
477
|
+
* `state` is the RUNTIME truth (isEmbeddingAvailable), not "is a model file on
|
|
478
|
+
* disk". Those diverge in both directions: a model deleted after a successful
|
|
479
|
+
* load leaves diarization working from memory, and a model present alongside a
|
|
480
|
+
* broken/ABI-mismatched sherpa-onnx never loads at all. `error` distinguishes
|
|
481
|
+
* that second case — a model is installed but the runtime rejected it — from a
|
|
482
|
+
* simply unconfigured install, which otherwise look identical to an operator.
|
|
483
|
+
*
|
|
484
|
+
* Declared after the module state it reads: hoisting it above `extractor`
|
|
485
|
+
* would make any import-time caller throw a ReferenceError on an
|
|
486
|
+
* unauthenticated endpoint. */
|
|
487
|
+
export function speakerModelState(): {
|
|
488
|
+
state: 'active' | 'unavailable' | 'error'
|
|
489
|
+
path: string | null
|
|
490
|
+
searched: string[]
|
|
491
|
+
} {
|
|
492
|
+
const path = resolveSpeakerModelPath()
|
|
493
|
+
const running = isEmbeddingAvailable()
|
|
494
|
+
const state = running ? 'active' : (path && initialized ? 'error' : 'unavailable')
|
|
495
|
+
return { state, path, searched: speakerModelCandidates() }
|
|
496
|
+
}
|
|
497
|
+
|
|
335
498
|
/** Compute actual cosine similarity between two raw embedding vectors */
|
|
336
499
|
export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
337
500
|
if (a.length !== b.length) return 0
|
package/server/routes/health.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
|
|
|
7
7
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
8
8
|
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
9
9
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
10
|
+
import { speakerModelState } from '../lib/speaker-embeddings.js'
|
|
10
11
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
11
12
|
import {
|
|
12
13
|
isWhisperLocalAvailable,
|
|
@@ -176,6 +177,12 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
176
177
|
|
|
177
178
|
checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
|
|
178
179
|
|
|
180
|
+
// Speaker diarization is opt-in (the ~26 MB voiceprint model ships outside the
|
|
181
|
+
// npm tarball), so publish its state rather than letting the amplitude
|
|
182
|
+
// fallback masquerade as working diarization. Availability only — the resolved
|
|
183
|
+
// path is a local filesystem detail and health is unauthenticated.
|
|
184
|
+
checks.speaker_id = speakerModelState().state
|
|
185
|
+
|
|
179
186
|
// Health is unauthenticated. Publish only availability; the actual CLI
|
|
180
187
|
// session id is a resumable runtime handle and belongs on authenticated
|
|
181
188
|
// query/debug surfaces.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// fresh client continues the sequence instead of reusing numbers.
|
|
7
7
|
//
|
|
8
8
|
// GET /api/message/:num → { globalMsgNum, date, query, response } (404 when unknown)
|
|
9
|
-
// GET /api/message-counter → { max }
|
|
9
|
+
// GET /api/message-counter → { max, era }
|
|
10
10
|
//
|
|
11
11
|
// Resolution order (per the prompt-queue/archive plan): live in-memory
|
|
12
12
|
// sessions first (covers the mirror's 15-minute lag), then day archives
|
|
@@ -19,6 +19,11 @@ import { getActiveSessions } from '../lib/conversation.js'
|
|
|
19
19
|
import { dataPath } from '../lib/data-dir.js'
|
|
20
20
|
import { localDay } from '../lib/local-day.js'
|
|
21
21
|
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
22
|
+
import {
|
|
23
|
+
LEGACY_MESSAGE_ERA,
|
|
24
|
+
currentMessageEra,
|
|
25
|
+
exchangeBelongsToEra,
|
|
26
|
+
} from '../lib/message-era.js'
|
|
22
27
|
|
|
23
28
|
// v6.3.0 — read archives from the SAME persistent location the archive-mirror
|
|
24
29
|
// writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
|
|
@@ -40,6 +45,7 @@ interface ExchangeLike {
|
|
|
40
45
|
content?: string
|
|
41
46
|
timestamp?: number
|
|
42
47
|
globalMsgNum?: number
|
|
48
|
+
messageEra?: string
|
|
43
49
|
attachments?: unknown
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -60,9 +66,16 @@ function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; re
|
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
function scanExchanges(
|
|
69
|
+
function scanExchanges(
|
|
70
|
+
exchanges: ExchangeLike[],
|
|
71
|
+
num: number,
|
|
72
|
+
date: string,
|
|
73
|
+
/** Pass null to match any era (post-reset short-ref fallback). */
|
|
74
|
+
era: string | null = currentMessageEra(),
|
|
75
|
+
): ResolvedGlobalMessage | null {
|
|
64
76
|
for (let i = 0; i < exchanges.length; i++) {
|
|
65
77
|
if (exchanges[i]?.globalMsgNum !== num) continue
|
|
78
|
+
if (era != null && !exchangeBelongsToEra(exchanges[i] ?? {}, era)) continue
|
|
66
79
|
const { query, response, attachments } = pairExchange(exchanges, i)
|
|
67
80
|
return {
|
|
68
81
|
globalMsgNum: num, date, query, response,
|
|
@@ -74,7 +87,11 @@ function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): Re
|
|
|
74
87
|
|
|
75
88
|
/** Resolve a global message number from the day archives, newest-first.
|
|
76
89
|
* Exported with an explicit dir for tests. */
|
|
77
|
-
export function resolveFromArchiveDir(
|
|
90
|
+
export function resolveFromArchiveDir(
|
|
91
|
+
dir: string,
|
|
92
|
+
num: number,
|
|
93
|
+
era: string | null = LEGACY_MESSAGE_ERA,
|
|
94
|
+
): ResolvedGlobalMessage | null {
|
|
78
95
|
let files: string[] = []
|
|
79
96
|
try {
|
|
80
97
|
files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse()
|
|
@@ -87,7 +104,12 @@ export function resolveFromArchiveDir(dir: string, num: number): ResolvedGlobalM
|
|
|
87
104
|
const chats = Array.isArray(day?.chats) ? day.chats : []
|
|
88
105
|
for (const chat of chats) {
|
|
89
106
|
const exchanges = Array.isArray(chat?.exchanges) ? chat.exchanges : []
|
|
90
|
-
const hit = scanExchanges(
|
|
107
|
+
const hit = scanExchanges(
|
|
108
|
+
exchanges,
|
|
109
|
+
num,
|
|
110
|
+
typeof day?.date === 'string' ? day.date : f.slice(0, 10),
|
|
111
|
+
era,
|
|
112
|
+
)
|
|
91
113
|
if (hit) return hit
|
|
92
114
|
}
|
|
93
115
|
} catch {
|
|
@@ -143,8 +165,8 @@ export function getArchiveChatMessagesNumbered(date: string, chatIndex: number)
|
|
|
143
165
|
return readArchiveChatNumbered(ARCHIVE_DIR, date, chatIndex)
|
|
144
166
|
}
|
|
145
167
|
|
|
146
|
-
/** Highest stamped number across the day archives (0 when none). */
|
|
147
|
-
export function maxGlobalMsgNumInDir(dir: string): number {
|
|
168
|
+
/** Highest stamped number across the day archives for one era (0 when none). */
|
|
169
|
+
export function maxGlobalMsgNumInDir(dir: string, era = LEGACY_MESSAGE_ERA): number {
|
|
148
170
|
let max = 0
|
|
149
171
|
let files: string[] = []
|
|
150
172
|
try {
|
|
@@ -157,6 +179,7 @@ export function maxGlobalMsgNumInDir(dir: string): number {
|
|
|
157
179
|
const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
|
|
158
180
|
for (const chat of Array.isArray(day?.chats) ? day.chats : []) {
|
|
159
181
|
for (const ex of Array.isArray(chat?.exchanges) ? chat.exchanges : []) {
|
|
182
|
+
if (!exchangeBelongsToEra(ex ?? {}, era)) continue
|
|
160
183
|
if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > max) max = ex.globalMsgNum
|
|
161
184
|
}
|
|
162
185
|
}
|
|
@@ -165,11 +188,14 @@ export function maxGlobalMsgNumInDir(dir: string): number {
|
|
|
165
188
|
return max
|
|
166
189
|
}
|
|
167
190
|
|
|
168
|
-
function resolveFromLiveSessions(
|
|
191
|
+
function resolveFromLiveSessions(
|
|
192
|
+
num: number,
|
|
193
|
+
era: string | null = currentMessageEra(),
|
|
194
|
+
): ResolvedGlobalMessage | null {
|
|
169
195
|
const today = localDay() // local calendar day, not UTC — a live ref in the user's evening must not label tomorrow
|
|
170
196
|
for (const session of getActiveSessions()) {
|
|
171
197
|
const exchanges = (session as { exchanges?: ExchangeLike[] }).exchanges ?? []
|
|
172
|
-
const hit = scanExchanges(exchanges, num, today)
|
|
198
|
+
const hit = scanExchanges(exchanges, num, today, era)
|
|
173
199
|
if (hit) return hit
|
|
174
200
|
}
|
|
175
201
|
return null
|
|
@@ -183,7 +209,13 @@ messageRefRouter.get('/message/:num', (req, res) => {
|
|
|
183
209
|
res.status(400).json({ error: 'invalid message number' })
|
|
184
210
|
return
|
|
185
211
|
}
|
|
186
|
-
|
|
212
|
+
// Prefer the current era so short refs stay unambiguous after a reset.
|
|
213
|
+
// Fall back across eras so "recall message 562" still works for pre-reset
|
|
214
|
+
// stamps once numbering restarts at #1.
|
|
215
|
+
const hit = resolveFromLiveSessions(num)
|
|
216
|
+
?? resolveFromArchiveDir(ARCHIVE_DIR, num, currentMessageEra())
|
|
217
|
+
?? resolveFromLiveSessions(num, null)
|
|
218
|
+
?? resolveFromArchiveDir(ARCHIVE_DIR, num, null)
|
|
187
219
|
if (!hit) {
|
|
188
220
|
res.status(404).json({ error: `message ${num} not found` })
|
|
189
221
|
return
|
|
@@ -192,11 +224,13 @@ messageRefRouter.get('/message/:num', (req, res) => {
|
|
|
192
224
|
})
|
|
193
225
|
|
|
194
226
|
messageRefRouter.get('/message-counter', (_req, res) => {
|
|
227
|
+
const era = currentMessageEra()
|
|
195
228
|
let liveMax = 0
|
|
196
229
|
for (const session of getActiveSessions()) {
|
|
197
230
|
for (const ex of ((session as { exchanges?: ExchangeLike[] }).exchanges ?? [])) {
|
|
231
|
+
if (!exchangeBelongsToEra(ex, era)) continue
|
|
198
232
|
if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
|
|
199
233
|
}
|
|
200
234
|
}
|
|
201
|
-
res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR)) })
|
|
235
|
+
res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR, era)), era })
|
|
202
236
|
})
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
markPromptDraftError,
|
|
14
14
|
getMissingChunkIndexes,
|
|
15
15
|
prunePromptDrafts,
|
|
16
|
+
transcriptQualityRank,
|
|
16
17
|
type PromptDraftTranscriptRecord,
|
|
17
18
|
} from '../lib/prompt-draft-store.js'
|
|
18
19
|
import {
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
OpenAIWhisperBudgetExhaustedError,
|
|
23
24
|
TranscriptionUnavailableError,
|
|
24
25
|
} from '../lib/transcribe-audio.js'
|
|
26
|
+
import { getHighQualityTranscriptionCapability } from '../lib/whisper-local.js'
|
|
25
27
|
import {
|
|
26
28
|
stripInlineHallucinationsOneShot,
|
|
27
29
|
stripInlineHallucinations,
|
|
@@ -191,16 +193,31 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
191
193
|
const current = loadPromptDraftMeta(draftId)
|
|
192
194
|
const warm = current?.warmTranscripts?.[String(chunkIndex)]
|
|
193
195
|
const cachedFinal = current?.finalTranscripts?.[String(chunkIndex)]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
196
|
+
// Trust the shared job text + mode key. Warm metadata is only used when
|
|
197
|
+
// it still matches this decode — a late Fast warm must not relabel HQ.
|
|
198
|
+
const warmMatches = Boolean(
|
|
199
|
+
warm
|
|
200
|
+
&& warm.hash === hash
|
|
201
|
+
&& warm.requestedMode === mode
|
|
202
|
+
&& warm.text === text,
|
|
203
|
+
)
|
|
204
|
+
const record: PromptDraftTranscriptRecord = {
|
|
205
|
+
text,
|
|
206
|
+
hash,
|
|
207
|
+
requestedMode: mode,
|
|
208
|
+
actualQuality: warmMatches
|
|
209
|
+
? warm!.actualQuality
|
|
210
|
+
: (mode === 'hq' ? 'hq' : 'fast'),
|
|
211
|
+
backend: warmMatches ? warm!.backend : 'shared-inflight',
|
|
212
|
+
degraded: warmMatches ? warm!.degraded : false,
|
|
213
|
+
...(warmMatches && warm!.degradationReason ? { degradationReason: warm!.degradationReason } : {}),
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
!cachedFinal
|
|
217
|
+
|| cachedFinal.hash !== hash
|
|
218
|
+
|| transcriptQualityRank(cachedFinal.actualQuality) < transcriptQualityRank(record.actualQuality)
|
|
219
|
+
) {
|
|
220
|
+
await markPromptDraftChunkTranscript(draftId, chunkIndex, record, 'final')
|
|
204
221
|
}
|
|
205
222
|
}
|
|
206
223
|
return text
|
|
@@ -245,6 +262,22 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
245
262
|
return job
|
|
246
263
|
}
|
|
247
264
|
|
|
265
|
+
function isReusableTranscript(
|
|
266
|
+
cached: PromptDraftTranscriptRecord | undefined,
|
|
267
|
+
hash: string,
|
|
268
|
+
mode: 'hq' | 'fast',
|
|
269
|
+
): boolean {
|
|
270
|
+
if (!cached || cached.hash !== hash || cached.requestedMode !== mode) return false
|
|
271
|
+
if (cached.backend.startsWith('legacy')) return false
|
|
272
|
+
if (mode === 'fast') return cached.actualQuality === 'fast' || cached.actualQuality === 'cloud'
|
|
273
|
+
// mode === 'hq'
|
|
274
|
+
if (cached.actualQuality === 'hq' || cached.actualQuality === 'cloud') return true
|
|
275
|
+
// Degraded turbo after an HQ request: retry when large-v3 can still run.
|
|
276
|
+
// If HQ is unavailable, reuse to avoid a pointless second turbo decode.
|
|
277
|
+
if (cached.actualQuality === 'fast') return !getHighQualityTranscriptionCapability().hqAvailable
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
|
|
248
281
|
async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: AutoCleanRequest, signal?: AbortSignal) {
|
|
249
282
|
const meta = loadPromptDraftMeta(draftId)
|
|
250
283
|
if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
|
|
@@ -262,17 +295,11 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
262
295
|
}
|
|
263
296
|
const current = loadPromptDraftMeta(draftId)
|
|
264
297
|
const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
265
|
-
// Reuse
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
const reusable = Boolean(
|
|
271
|
-
cached
|
|
272
|
-
&& cached.hash === hash
|
|
273
|
-
&& cached.requestedMode === mode
|
|
274
|
-
&& !cached.backend.startsWith('legacy'),
|
|
275
|
-
)
|
|
298
|
+
// Reuse only a decode that still matches the requested mode's quality bar.
|
|
299
|
+
// When Settings HQ is available, never treat a degraded turbo warm as final
|
|
300
|
+
// — that left finished chats on Fast text while REVIEW looked "HQ ready".
|
|
301
|
+
// Legacy records stay excluded (reconstructed provenance).
|
|
302
|
+
const reusable = isReusableTranscript(cached, hash, mode)
|
|
276
303
|
const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
|
|
277
304
|
const text = sanitizeTranscript(draftId, raw, !reusable)
|
|
278
305
|
if (text.trim()) {
|
package/server/routes/query.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
MaintenanceLifecycleError,
|
|
19
19
|
maintenanceErrorPayload,
|
|
20
20
|
} from '../lib/maintenance-lifecycle.js'
|
|
21
|
+
import { currentMessageEra, LEGACY_MESSAGE_ERA } from '../lib/message-era.js'
|
|
21
22
|
|
|
22
23
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
23
24
|
WebSearch: 'Searching web...',
|
|
@@ -38,7 +39,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
38
39
|
}
|
|
39
40
|
throw error
|
|
40
41
|
}
|
|
41
|
-
const { query, sessionId, model, effort, reference, globalMsgNum } = req.body
|
|
42
|
+
const { query, sessionId, model, effort, reference, globalMsgNum, messageEra } = req.body
|
|
42
43
|
const activityToolMode = req.body.activityToolMode === 'off' || req.body.activityToolMode === 'preview'
|
|
43
44
|
? req.body.activityToolMode
|
|
44
45
|
: 'status'
|
|
@@ -46,6 +47,24 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
46
47
|
// omit/unknown must not silently force Ask (6.16.3 durable-only gap).
|
|
47
48
|
const cursorExecutionMode = req.body.cursorExecutionMode === 'ask' ? 'ask' as const : 'agent' as const
|
|
48
49
|
|
|
50
|
+
// Once a reset era exists, reject pre-era clients before they can stamp a
|
|
51
|
+
// five-digit number into the fresh namespace. Companion learns the era from
|
|
52
|
+
// /api/message-counter and includes it on every query.
|
|
53
|
+
const activeMessageEra = currentMessageEra()
|
|
54
|
+
if (activeMessageEra !== LEGACY_MESSAGE_ERA && messageEra !== activeMessageEra) {
|
|
55
|
+
console.warn('[query] message era mismatch', {
|
|
56
|
+
sessionId: typeof sessionId === 'string' ? sessionId : undefined,
|
|
57
|
+
sentEra: typeof messageEra === 'string' ? messageEra : '(missing)',
|
|
58
|
+
expectedEra: activeMessageEra,
|
|
59
|
+
})
|
|
60
|
+
maintenanceLease.release()
|
|
61
|
+
return res.status(409).json({
|
|
62
|
+
error: 'message_era_mismatch',
|
|
63
|
+
detail: 'Reopen or update COS Glasses to start the fresh message list.',
|
|
64
|
+
era: activeMessageEra,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
// Resolve durable attachment ids and legacy base64 images through one
|
|
50
69
|
// validation/normalization path before opening SSE.
|
|
51
70
|
let resolvedAttachments
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { Router } from 'express'
|
|
3
3
|
import { readFileSync } from 'fs'
|
|
4
4
|
import { join } from 'path'
|
|
5
|
-
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions } from '../lib/conversation.js'
|
|
5
|
+
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel } from '../lib/conversation.js'
|
|
6
6
|
import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
|
|
7
7
|
import { getArchiveDayMessages } from '../lib/archive.js'
|
|
8
8
|
import { localDay } from '../lib/local-day.js'
|
|
9
9
|
import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
|
|
10
10
|
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
11
|
+
import { currentMessageEra, exchangeBelongsToEra } from '../lib/message-era.js'
|
|
11
12
|
|
|
12
13
|
export const sessionsRouter = Router()
|
|
13
14
|
|
|
@@ -57,20 +58,24 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
|
57
58
|
timestamp: number
|
|
58
59
|
no?: number
|
|
59
60
|
sessionId: string
|
|
61
|
+
modelPreference?: string
|
|
60
62
|
attachments?: MediaAttachmentRef[]
|
|
61
63
|
}> = []
|
|
64
|
+
const session = getSessionRaw(req.params.id)
|
|
62
65
|
for (let i = 0; i < exchanges.length; i++) {
|
|
63
66
|
const ex = exchanges[i]
|
|
64
67
|
if (ex.role === 'user') {
|
|
65
68
|
const next = exchanges[i + 1]
|
|
66
69
|
if (next && next.role === 'assistant') {
|
|
67
70
|
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
71
|
+
const modelPreference = resolveExchangePairModel(ex, next, session?.modelPreference)
|
|
68
72
|
messages.push({
|
|
69
73
|
query: ex.content,
|
|
70
74
|
text: next.content,
|
|
71
75
|
timestamp: next.timestamp,
|
|
72
76
|
sessionId: req.params.id,
|
|
73
77
|
...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
|
|
78
|
+
...(modelPreference ? { modelPreference } : {}),
|
|
74
79
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
75
80
|
})
|
|
76
81
|
i++ // skip the assistant exchange
|
|
@@ -241,11 +246,14 @@ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
|
|
|
241
246
|
// back to the archived chat's sessionId via getArchiveDayMessages.
|
|
242
247
|
sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
243
248
|
const todayDate = localDay()
|
|
249
|
+
const era = currentMessageEra()
|
|
244
250
|
|
|
245
|
-
const archivedMessages = getArchiveDayMessages(todayDate)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
251
|
+
const archivedMessages = getArchiveDayMessages(todayDate)
|
|
252
|
+
.filter(m => exchangeBelongsToEra(m, era))
|
|
253
|
+
.map(m => ({
|
|
254
|
+
...m,
|
|
255
|
+
source: 'archive' as const,
|
|
256
|
+
}))
|
|
249
257
|
|
|
250
258
|
const liveMessages: Array<{
|
|
251
259
|
query: string
|
|
@@ -255,6 +263,9 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
255
263
|
sessionId: string
|
|
256
264
|
source: 'live'
|
|
257
265
|
no?: number
|
|
266
|
+
globalMsgNum?: number
|
|
267
|
+
messageEra?: string
|
|
268
|
+
modelPreference?: string
|
|
258
269
|
attachments?: MediaAttachmentRef[]
|
|
259
270
|
}> = []
|
|
260
271
|
const liveSessions = getActiveSessions()
|
|
@@ -264,9 +275,13 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
264
275
|
for (let i = 0; i < session.exchanges.length; i++) {
|
|
265
276
|
const ex = session.exchanges[i]
|
|
266
277
|
if (ex.role === 'user') {
|
|
278
|
+
if (!exchangeBelongsToEra(ex, era)) continue
|
|
267
279
|
const next = session.exchanges[i + 1]
|
|
268
280
|
if (next && next.role === 'assistant') {
|
|
269
281
|
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
282
|
+
const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
|
|
283
|
+
const messageEra = ex.messageEra ?? next.messageEra
|
|
284
|
+
const modelPreference = resolveExchangePairModel(ex, next, session.modelPreference)
|
|
270
285
|
liveMessages.push({
|
|
271
286
|
query: ex.content,
|
|
272
287
|
text: next.content,
|
|
@@ -274,7 +289,9 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
274
289
|
chatIndex: -1,
|
|
275
290
|
sessionId: session.id,
|
|
276
291
|
source: 'live',
|
|
277
|
-
...(
|
|
292
|
+
...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
|
|
293
|
+
...(messageEra ? { messageEra } : {}),
|
|
294
|
+
...(modelPreference ? { modelPreference } : {}),
|
|
278
295
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
279
296
|
})
|
|
280
297
|
i++
|
package/server/routes/voice.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
8
8
|
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
|
|
9
9
|
import { statSync } from 'node:fs'
|
|
10
10
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
11
|
+
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
11
12
|
|
|
12
13
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
13
14
|
const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
|
|
@@ -18,7 +19,10 @@ export const voiceRouter = Router()
|
|
|
18
19
|
// POST /api/voice/enroll — accept WAV audio, extract embedding, store as profile
|
|
19
20
|
voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
20
21
|
try {
|
|
21
|
-
|
|
22
|
+
// Default to the configured wearer label ('Me' unless owner_speaker_label
|
|
23
|
+
// is set). Hardcoding one user's initials here enrolled every other install
|
|
24
|
+
// under a stranger's name.
|
|
25
|
+
const name = (req.query.name as string) || getOwnerSpeakerLabel()
|
|
22
26
|
|
|
23
27
|
// Collect raw audio body
|
|
24
28
|
const buffers: Buffer[] = []
|
|
@@ -38,10 +42,12 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
|
38
42
|
}
|
|
39
43
|
})
|
|
40
44
|
|
|
41
|
-
// GET /api/voice/status — is
|
|
45
|
+
// GET /api/voice/status — is the wearer enrolled?
|
|
42
46
|
voiceRouter.get('/voice/status', (_req, res) => {
|
|
47
|
+
const owner = getOwnerSpeakerLabel()
|
|
43
48
|
res.json({
|
|
44
|
-
|
|
49
|
+
owner,
|
|
50
|
+
enrolled: isEnrolled(owner),
|
|
45
51
|
speakers: getAllSpeakerNames(),
|
|
46
52
|
})
|
|
47
53
|
})
|