@gotcos/glasses-server 6.12.7 → 6.14.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/CHANGELOG.md +11 -0
- package/README.md +10 -0
- package/bin/cli.cjs +12 -0
- package/bin/managed-server.cjs +28 -0
- package/managed-runtime-contract.json +23 -0
- package/package.json +6 -3
- package/server/index.ts +106 -35
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- package/server/lib/launch-dir.ts +13 -7
- package/server/lib/maintenance-lifecycle.ts +735 -0
- package/server/lib/managed-runtime.ts +44 -0
- package/server/lib/openai-tts-budget.ts +142 -0
- package/server/lib/prompt-edit.ts +224 -0
- package/server/lib/query-job-coordinator.ts +36 -4
- package/server/lib/query-job-runtime.ts +38 -26
- package/server/lib/recovery-activity.ts +168 -0
- package/server/lib/speaker-trainer.ts +532 -0
- package/server/lib/tts-cache.ts +596 -0
- package/server/routes/bookmarks.ts +59 -0
- package/server/routes/glossary.ts +153 -0
- package/server/routes/handoffs.ts +101 -0
- package/server/routes/health.ts +15 -12
- package/server/routes/maintenance.ts +160 -0
- package/server/routes/meeting.ts +25 -8
- package/server/routes/openai-compat.ts +82 -36
- package/server/routes/prompt-drafts.ts +51 -8
- package/server/routes/prompt-edit.ts +33 -0
- package/server/routes/query.ts +52 -24
- package/server/routes/recovery.ts +76 -0
- package/server/routes/transcribe-stream.ts +49 -3
- package/server/routes/transcribe.ts +14 -0
- package/server/routes/tts.ts +709 -0
- package/server/routes/voice.ts +317 -0
- package/shared/handoff-intent.ts +90 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Voice enrollment, status, and multi-speaker training endpoints
|
|
2
|
+
|
|
3
|
+
import { Router } from 'express'
|
|
4
|
+
import { errMsg } from '../lib/utils.js'
|
|
5
|
+
import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
|
|
9
|
+
import { statSync } from 'node:fs'
|
|
10
|
+
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
11
|
+
|
|
12
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
13
|
+
const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
|
|
14
|
+
const EXT_AUDIO_DIR = resolve(__dirname, '..', 'data', 'ext-audio')
|
|
15
|
+
|
|
16
|
+
export const voiceRouter = Router()
|
|
17
|
+
|
|
18
|
+
// POST /api/voice/enroll — accept WAV audio, extract embedding, store as profile
|
|
19
|
+
voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
const name = (req.query.name as string) || 'MU'
|
|
22
|
+
|
|
23
|
+
// Collect raw audio body
|
|
24
|
+
const buffers: Buffer[] = []
|
|
25
|
+
for await (const chunk of req) {
|
|
26
|
+
buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
27
|
+
}
|
|
28
|
+
const audioBuffer = Buffer.concat(buffers)
|
|
29
|
+
|
|
30
|
+
if (audioBuffer.length < 1000) {
|
|
31
|
+
return res.status(400).json({ success: false, error: 'Audio too short — need at least 5 seconds' })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const result = enrollSpeaker(name, audioBuffer)
|
|
35
|
+
res.json(result)
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
res.status(500).json({ success: false, error: errMsg(err) })
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// GET /api/voice/status — is MU enrolled?
|
|
42
|
+
voiceRouter.get('/voice/status', (_req, res) => {
|
|
43
|
+
res.json({
|
|
44
|
+
enrolled: isEnrolled('MU'),
|
|
45
|
+
speakers: getAllSpeakerNames(),
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// POST /api/voice/identify — one-shot identification (testing)
|
|
50
|
+
voiceRouter.post('/voice/identify', async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const buffers: Buffer[] = []
|
|
53
|
+
for await (const chunk of req) {
|
|
54
|
+
buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
55
|
+
}
|
|
56
|
+
const audioBuffer = Buffer.concat(buffers)
|
|
57
|
+
|
|
58
|
+
const result = identifySpeaker(audioBuffer)
|
|
59
|
+
res.json(result ?? { speaker: 'Unknown', similarity: 0 })
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
res.status(500).json({ error: errMsg(err) })
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// POST /api/voice/train — train voiceprints from Fireflies meeting audio
|
|
66
|
+
voiceRouter.post('/voice/train', async (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
const { speakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
|
|
69
|
+
const report = await trainFromFireflies({
|
|
70
|
+
speakerNames,
|
|
71
|
+
minSegments,
|
|
72
|
+
minSegmentDuration,
|
|
73
|
+
limit,
|
|
74
|
+
maxEmbeddingsPerSpeaker,
|
|
75
|
+
fresh,
|
|
76
|
+
})
|
|
77
|
+
res.json(report)
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
res.status(500).json({ error: errMsg(err) })
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// GET /api/voice/training-status — show trained speakers and enrollment state
|
|
84
|
+
voiceRouter.get('/voice/training-status', async (_req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const status = await getTrainingStatus()
|
|
87
|
+
res.json(status)
|
|
88
|
+
} catch (err: unknown) {
|
|
89
|
+
res.status(500).json({ error: errMsg(err) })
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// POST /api/voice/train-g2 — train from saved G2-mic audio chunks
|
|
94
|
+
// These accumulate during meetings for speakers who need more embeddings
|
|
95
|
+
voiceRouter.post('/voice/train-g2', async (req, res) => {
|
|
96
|
+
try {
|
|
97
|
+
const targetSpeaker = req.body?.speaker as string | undefined
|
|
98
|
+
if (!existsSync(AUDIO_SAVE_DIR)) {
|
|
99
|
+
return res.json({ trained: 0, speakers: [], message: 'No saved G2 audio yet' })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
|
|
103
|
+
.filter(d => d.isDirectory())
|
|
104
|
+
.filter(d => !targetSpeaker || d.name === targetSpeaker.replace(/\s+/g, '_'))
|
|
105
|
+
|
|
106
|
+
const results: Array<{ speaker: string; chunks: number; enrolled: number }> = []
|
|
107
|
+
|
|
108
|
+
for (const dir of speakerDirs) {
|
|
109
|
+
const speakerName = dir.name.replace(/_/g, ' ')
|
|
110
|
+
const speakerPath = resolve(AUDIO_SAVE_DIR, dir.name)
|
|
111
|
+
const wavFiles = readdirSync(speakerPath).filter(f => f.endsWith('.wav')).sort()
|
|
112
|
+
|
|
113
|
+
if (wavFiles.length === 0) continue
|
|
114
|
+
|
|
115
|
+
// Extract all embeddings, select most diverse
|
|
116
|
+
const embeddings: Float32Array[] = []
|
|
117
|
+
for (const wav of wavFiles) {
|
|
118
|
+
const buffer = readFileSync(resolve(speakerPath, wav))
|
|
119
|
+
const emb = extractEmbedding(buffer)
|
|
120
|
+
if (emb) embeddings.push(emb)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (embeddings.length === 0) {
|
|
124
|
+
results.push({ speaker: speakerName, chunks: wavFiles.length, enrolled: 0 })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Enroll diverse embeddings (enrollEmbedding handles diversity gate + FIFO cap)
|
|
129
|
+
let enrolled = 0
|
|
130
|
+
for (const emb of embeddings) {
|
|
131
|
+
const result = enrollEmbedding(speakerName, emb, 'g2-training')
|
|
132
|
+
if (result.success) enrolled++
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
results.push({ speaker: speakerName, chunks: wavFiles.length, enrolled })
|
|
136
|
+
|
|
137
|
+
// Clean up processed audio
|
|
138
|
+
for (const wav of wavFiles) {
|
|
139
|
+
try { unlinkSync(resolve(speakerPath, wav)) } catch {}
|
|
140
|
+
}
|
|
141
|
+
try { rmdirSync(speakerPath) } catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const totalEnrolled = results.reduce((sum, r) => sum + r.enrolled, 0)
|
|
145
|
+
res.json({ trained: totalEnrolled, speakers: results })
|
|
146
|
+
} catch (err: unknown) {
|
|
147
|
+
res.status(500).json({ error: errMsg(err) })
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// GET /api/voice/saved-audio — show accumulated G2 training audio
|
|
152
|
+
voiceRouter.get('/voice/saved-audio', (_req, res) => {
|
|
153
|
+
try {
|
|
154
|
+
if (!existsSync(AUDIO_SAVE_DIR)) {
|
|
155
|
+
return res.json({ speakers: [] })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
|
|
159
|
+
.filter(d => d.isDirectory())
|
|
160
|
+
|
|
161
|
+
const speakers = speakerDirs.map(d => {
|
|
162
|
+
const speakerPath = resolve(AUDIO_SAVE_DIR, d.name)
|
|
163
|
+
const wavFiles = readdirSync(speakerPath).filter(f => f.endsWith('.wav'))
|
|
164
|
+
return {
|
|
165
|
+
name: d.name.replace(/_/g, ' '),
|
|
166
|
+
chunks: wavFiles.length,
|
|
167
|
+
currentEmbeddings: getEmbeddingCount(d.name.replace(/_/g, ' ')),
|
|
168
|
+
}
|
|
169
|
+
}).filter(s => s.chunks > 0)
|
|
170
|
+
|
|
171
|
+
res.json({ speakers })
|
|
172
|
+
} catch (err: unknown) {
|
|
173
|
+
res.status(500).json({ error: errMsg(err) })
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// GET /api/voice/ext-audio — list saved unrecognized speaker audio (72hr retention)
|
|
178
|
+
voiceRouter.get('/voice/ext-audio', (_req, res) => {
|
|
179
|
+
try {
|
|
180
|
+
if (!existsSync(EXT_AUDIO_DIR)) {
|
|
181
|
+
return res.json({ sessions: [], totalChunks: 0 })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sessionDirs = readdirSync(EXT_AUDIO_DIR, { withFileTypes: true })
|
|
185
|
+
.filter(d => d.isDirectory())
|
|
186
|
+
|
|
187
|
+
const sessions = sessionDirs.map(d => {
|
|
188
|
+
const dirPath = resolve(EXT_AUDIO_DIR, d.name)
|
|
189
|
+
const wavFiles = readdirSync(dirPath).filter(f => f.endsWith('.wav')).sort()
|
|
190
|
+
let oldestMs = Date.now(), newestMs = 0
|
|
191
|
+
for (const f of wavFiles) {
|
|
192
|
+
try {
|
|
193
|
+
const { mtimeMs } = statSync(resolve(dirPath, f))
|
|
194
|
+
if (mtimeMs < oldestMs) oldestMs = mtimeMs
|
|
195
|
+
if (mtimeMs > newestMs) newestMs = mtimeMs
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
const ageHours = ((Date.now() - oldestMs) / (60 * 60 * 1000)).toFixed(1)
|
|
199
|
+
return {
|
|
200
|
+
sessionId: d.name,
|
|
201
|
+
chunks: wavFiles.length,
|
|
202
|
+
ageHours: parseFloat(ageHours),
|
|
203
|
+
expiresIn: `${Math.max(0, 72 - parseFloat(ageHours)).toFixed(1)}h`,
|
|
204
|
+
}
|
|
205
|
+
}).filter(s => s.chunks > 0)
|
|
206
|
+
|
|
207
|
+
res.json({
|
|
208
|
+
sessions,
|
|
209
|
+
totalChunks: sessions.reduce((sum, s) => sum + s.chunks, 0),
|
|
210
|
+
})
|
|
211
|
+
} catch (err: unknown) {
|
|
212
|
+
res.status(500).json({ error: errMsg(err) })
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// POST /api/voice/enroll-ext — enroll saved Ext audio under a speaker name
|
|
217
|
+
// Body: { name: "Chuks", sessionId?: "abc123" }
|
|
218
|
+
// If sessionId provided, only enroll from that session. Otherwise, enroll from all ext sessions.
|
|
219
|
+
voiceRouter.post('/voice/enroll-ext', async (req, res) => {
|
|
220
|
+
try {
|
|
221
|
+
const { name, sessionId } = req.body ?? {}
|
|
222
|
+
if (!name || typeof name !== 'string' || name.length < 2) {
|
|
223
|
+
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!existsSync(EXT_AUDIO_DIR)) {
|
|
227
|
+
return res.json({ enrolled: 0, message: 'No ext-audio available' })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Collect target directories
|
|
231
|
+
const targetDirs: string[] = []
|
|
232
|
+
if (sessionId) {
|
|
233
|
+
const dirPath = resolve(EXT_AUDIO_DIR, sessionId)
|
|
234
|
+
if (existsSync(dirPath)) targetDirs.push(dirPath)
|
|
235
|
+
else return res.status(404).json({ error: `Session ${sessionId} not found in ext-audio` })
|
|
236
|
+
} else {
|
|
237
|
+
const sessionDirs = readdirSync(EXT_AUDIO_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
|
|
238
|
+
for (const d of sessionDirs) targetDirs.push(resolve(EXT_AUDIO_DIR, d.name))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Extract all embeddings from WAV chunks
|
|
242
|
+
const allEmbeddings: Float32Array[] = []
|
|
243
|
+
let totalChunks = 0
|
|
244
|
+
for (const dirPath of targetDirs) {
|
|
245
|
+
const wavFiles = readdirSync(dirPath).filter(f => f.endsWith('.wav')).sort()
|
|
246
|
+
for (const wav of wavFiles) {
|
|
247
|
+
totalChunks++
|
|
248
|
+
const buffer = readFileSync(resolve(dirPath, wav))
|
|
249
|
+
const emb = extractEmbedding(buffer)
|
|
250
|
+
if (emb) allEmbeddings.push(emb)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (allEmbeddings.length === 0) {
|
|
255
|
+
return res.json({ enrolled: 0, totalChunks, message: 'No valid embeddings extracted from ext audio' })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Greedy diversity selection: pick most diverse embeddings (max 20)
|
|
259
|
+
const maxToEnroll = 20
|
|
260
|
+
const selected = greedyDiversitySelect(allEmbeddings, maxToEnroll)
|
|
261
|
+
|
|
262
|
+
// Enroll selected embeddings
|
|
263
|
+
let enrolled = 0
|
|
264
|
+
for (const emb of selected) {
|
|
265
|
+
const result = enrollEmbedding(name, emb, 'ext-retroactive', true)
|
|
266
|
+
if (result.success) enrolled++
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Clean up enrolled ext-audio
|
|
270
|
+
for (const dirPath of targetDirs) {
|
|
271
|
+
try { rmSync(dirPath, { recursive: true, force: true }) } catch {}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
res.json({
|
|
275
|
+
speaker: name,
|
|
276
|
+
enrolled,
|
|
277
|
+
totalChunks,
|
|
278
|
+
embeddingsExtracted: allEmbeddings.length,
|
|
279
|
+
selectedDiverse: selected.length,
|
|
280
|
+
message: `Enrolled ${enrolled} diverse embeddings for ${name} from ${totalChunks} ext audio chunks`,
|
|
281
|
+
})
|
|
282
|
+
} catch (err: unknown) {
|
|
283
|
+
res.status(500).json({ error: errMsg(err) })
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
/** Greedy diversity selection — pick N most acoustically diverse embeddings */
|
|
288
|
+
function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
|
|
289
|
+
if (embeddings.length <= maxN) return embeddings
|
|
290
|
+
|
|
291
|
+
// Find the most dissimilar pair as seeds
|
|
292
|
+
let maxDist = -1, seedA = 0, seedB = 1
|
|
293
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
294
|
+
for (let j = i + 1; j < embeddings.length; j++) {
|
|
295
|
+
const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[j])
|
|
296
|
+
if (dist > maxDist) { maxDist = dist; seedA = i; seedB = j }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const selected = new Set([seedA, seedB])
|
|
301
|
+
while (selected.size < maxN) {
|
|
302
|
+
let bestIdx = -1, bestMinDist = -1
|
|
303
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
304
|
+
if (selected.has(i)) continue
|
|
305
|
+
let minDist = Infinity
|
|
306
|
+
for (const s of selected) {
|
|
307
|
+
const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[s])
|
|
308
|
+
if (dist < minDist) minDist = dist
|
|
309
|
+
}
|
|
310
|
+
if (minDist > bestMinDist) { bestMinDist = minDist; bestIdx = i }
|
|
311
|
+
}
|
|
312
|
+
if (bestIdx === -1) break
|
|
313
|
+
selected.add(bestIdx)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return [...selected].map(i => embeddings[i])
|
|
317
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export const HANDOFF_CODE_LENGTH = 8
|
|
2
|
+
export const HANDOFF_CODE_PATTERN = /^[0-9A-HJKMNP-TV-Z]{8}$/
|
|
3
|
+
|
|
4
|
+
const CODE_CHARS = /[0-9A-Z]/g
|
|
5
|
+
|
|
6
|
+
function normalizeVoiceCommand(text: string): string {
|
|
7
|
+
return text
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/[.,!?;:]+/g, ' ')
|
|
10
|
+
.replace(/\s+/g, ' ')
|
|
11
|
+
.trim()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HandoffPickupIntent {
|
|
15
|
+
kind: 'latest' | 'code'
|
|
16
|
+
code?: string
|
|
17
|
+
followup: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HandoffCreateIntent {
|
|
21
|
+
target: 'desktop' | 'g2' | 'codex' | 'claude'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeHandoffCode(input: string): string | null {
|
|
25
|
+
const raw = input
|
|
26
|
+
.toUpperCase()
|
|
27
|
+
.replace(/O/g, '0')
|
|
28
|
+
.replace(/[IL]/g, '1')
|
|
29
|
+
.match(CODE_CHARS)
|
|
30
|
+
?.join('') ?? ''
|
|
31
|
+
if (raw.length !== HANDOFF_CODE_LENGTH) return null
|
|
32
|
+
if (!HANDOFF_CODE_PATTERN.test(raw)) return null
|
|
33
|
+
return raw
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseHandoffPickupIntent(text: string): HandoffPickupIntent | null {
|
|
37
|
+
const trimmed = normalizeVoiceCommand(text)
|
|
38
|
+
if (!trimmed) return null
|
|
39
|
+
|
|
40
|
+
const latest = trimmed.match(/^(?:pick\s*up|pickup|resume|continue)\s+(?:where\s+i\s+left\s+off|latest|last\s+handoff|my\s+handoff)(?:\s+(.*))?$/i)
|
|
41
|
+
if (latest) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'latest',
|
|
44
|
+
followup: (latest[1] ?? '').replace(/^(?:and|then|:)\s+/i, '').trim(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const coded = trimmed.match(/^(?:pick\s*up|pickup|resume|continue)(?:\s+(handoff|code))?\s+(.+)$/i)
|
|
49
|
+
if (!coded) return null
|
|
50
|
+
const explicitCodeWord = !!coded[1]
|
|
51
|
+
const rest = coded[2] ?? ''
|
|
52
|
+
let candidate = ''
|
|
53
|
+
let consumed = 0
|
|
54
|
+
for (let i = 0; i < rest.length; i++) {
|
|
55
|
+
const ch = rest[i]
|
|
56
|
+
if (/[0-9A-Z]/i.test(ch)) {
|
|
57
|
+
candidate += ch
|
|
58
|
+
if (candidate.length === HANDOFF_CODE_LENGTH) {
|
|
59
|
+
consumed = i + 1
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
} else if (candidate.length > 0 && !/[\s-]/.test(ch)) {
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const rawCandidate = rest.slice(0, consumed).trim()
|
|
67
|
+
const isDisplayedBareCode = rawCandidate === rawCandidate.toUpperCase()
|
|
68
|
+
&& rawCandidate.replace(/[\s-]/g, '').length === HANDOFF_CODE_LENGTH
|
|
69
|
+
if (!explicitCodeWord && !/\d/.test(candidate) && !isDisplayedBareCode) return null
|
|
70
|
+
const code = normalizeHandoffCode(candidate)
|
|
71
|
+
if (!code) return null
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
kind: 'code',
|
|
75
|
+
code,
|
|
76
|
+
followup: rest.slice(consumed).trim().replace(/^(?:and|then|:)\s+/i, '').trim(),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseHandoffCreateIntent(text: string): HandoffCreateIntent | null {
|
|
81
|
+
const match = normalizeVoiceCommand(text).match(/^(?:handoff|hand[-\s]+off|pass|send)\s+(?:this|this\s+work|current\s+work|current\s+chat|current\s+message)(?:\s+(?:to|over\s+to)(?:\s+my)?\s+(desktop|g2|codex|claude(?:\s+code)?|mac|laptop))?$/i)
|
|
82
|
+
if (!match) return null
|
|
83
|
+
const rawTarget = match[1]?.toLowerCase()
|
|
84
|
+
const target = rawTarget?.startsWith('claude')
|
|
85
|
+
? 'claude'
|
|
86
|
+
: rawTarget === 'mac' || rawTarget === 'laptop'
|
|
87
|
+
? 'desktop'
|
|
88
|
+
: (rawTarget ?? 'desktop') as HandoffCreateIntent['target']
|
|
89
|
+
return { target }
|
|
90
|
+
}
|