@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 6.14.0
|
|
2
|
+
|
|
3
|
+
- Add voice (TTS + speaker) and additive glasses routes to the public server: `tts`, `voice`, `glossary`, `handoffs`, `recovery`, `prompt-edit`, `bookmarks`. Brings server-side voice + companion utilities to public installs; COS-integration routes remain private.
|
|
4
|
+
|
|
1
5
|
# Changelog
|
|
2
6
|
|
|
3
7
|
## 6.12.7
|
|
@@ -500,3 +504,10 @@ it directly, with no second repository to clone.
|
|
|
500
504
|
can reach the server over your mesh/LAN. The IP allowlist blocks public traffic.
|
|
501
505
|
- **Persistent config** at `~/.cos-glasses/.env`.
|
|
502
506
|
- Requires Node.js 20.11+.
|
|
507
|
+
# 6.13.0
|
|
508
|
+
|
|
509
|
+
- Added a non-interactive managed-server entrypoint for the COS Control macOS app.
|
|
510
|
+
- Added authenticated maintenance status and guarded local Whisper restart contracts.
|
|
511
|
+
- Added `--prepare-only` to the existing guided launcher so first-run dependencies can be prepared without leaving a second server process running.
|
|
512
|
+
- Added a provider-neutral managed work-folder setting while preserving the existing interactive launch-directory behavior.
|
|
513
|
+
- Kept the existing `npx @gotcos/glasses-server` foreground workflow fully compatible.
|
package/README.md
CHANGED
|
@@ -11,6 +11,16 @@ API key is pasted into the phone for chat.
|
|
|
11
11
|
npx --yes @gotcos/glasses-server@latest
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
For the optional COS Control macOS menu bar app, prepare dependencies without
|
|
15
|
+
leaving a foreground server running:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx --yes @gotcos/glasses-server@latest --prepare-only
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
COS Control then installs the same npm package as a launchd-managed runtime.
|
|
22
|
+
The original foreground command remains supported and unchanged.
|
|
23
|
+
|
|
14
24
|
The launcher checks Node, finds your CLI, checks voice and image processing,
|
|
15
25
|
downloads the local voice model when needed, writes `~/.cos-glasses/.env`, and
|
|
16
26
|
starts the server on `0.0.0.0:3141`. On boot it prints
|
package/bin/cli.cjs
CHANGED
|
@@ -41,6 +41,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
41
41
|
console.log('')
|
|
42
42
|
console.log(' Usage:')
|
|
43
43
|
console.log(' npx --yes @gotcos/glasses-server@latest')
|
|
44
|
+
console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
|
|
44
45
|
console.log('')
|
|
45
46
|
console.log(' Requirements:')
|
|
46
47
|
console.log(' - Node.js 20.11+')
|
|
@@ -174,6 +175,17 @@ try {
|
|
|
174
175
|
process.exit(1)
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
// Controller probes are deliberately read-only. They verify Node, agent auth,
|
|
179
|
+
// and the packaged runtime without creating config, downloading models,
|
|
180
|
+
// changing permissions, or starting a listener.
|
|
181
|
+
if (process.argv.includes('--prepare-only')) {
|
|
182
|
+
console.log('')
|
|
183
|
+
console.log(green(' ✓ Non-mutating readiness check complete'))
|
|
184
|
+
console.log(' COS Control can perform guided installation without hidden setup side effects.')
|
|
185
|
+
console.log('')
|
|
186
|
+
process.exit(0)
|
|
187
|
+
}
|
|
188
|
+
|
|
177
189
|
// Step 4: persistent config at ~/.cos-glasses/ (survives npx cache churn)
|
|
178
190
|
function securePrivateDirectory(dir) {
|
|
179
191
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Non-interactive entrypoint for trusted local service managers such as
|
|
4
|
+
// COS Control. The existing glasses-server CLI remains the interactive setup
|
|
5
|
+
// path; this launcher assumes ~/.cos-glasses/.env is already configured.
|
|
6
|
+
|
|
7
|
+
const { resolve } = require('node:path')
|
|
8
|
+
const packageJson = require('../package.json')
|
|
9
|
+
|
|
10
|
+
const PKG_ROOT = resolve(__dirname, '..')
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
require('tsx/cjs')
|
|
14
|
+
} catch {
|
|
15
|
+
console.error('[cos-managed] Package dependencies are incomplete; reinstall @gotcos/glasses-server.')
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const workDir = process.env.COS_WORKDIR?.trim()
|
|
20
|
+
process.env.COS_MANAGED = '1'
|
|
21
|
+
process.env.COS_ENTRYPOINT = 'managed-server'
|
|
22
|
+
process.env.COS_SERVER_VERSION = packageJson.version
|
|
23
|
+
if (workDir) process.env.COS_LAUNCH_DIR = workDir
|
|
24
|
+
|
|
25
|
+
// The launchd-owned PID is the listener owner. Keeping the listener in this
|
|
26
|
+
// process removes the supervisor/child ambiguity that made lifecycle proof and
|
|
27
|
+
// crash receipts unreliable.
|
|
28
|
+
require(resolve(PKG_ROOT, 'server/index.ts'))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"contractVersion": 2,
|
|
4
|
+
"entrypoint": "bin/managed-server.cjs",
|
|
5
|
+
"listenerOwnership": "entrypoint-process",
|
|
6
|
+
"requiredEnvironment": [
|
|
7
|
+
"COS_API_TOKEN",
|
|
8
|
+
"COS_SERVER_GENERATION_ID",
|
|
9
|
+
"COS_MAINTENANCE_GATE_PATH"
|
|
10
|
+
],
|
|
11
|
+
"optionalEnvironment": [
|
|
12
|
+
"COS_WORKDIR",
|
|
13
|
+
"PORT",
|
|
14
|
+
"HTTPS_PORT",
|
|
15
|
+
"BIND_HOST",
|
|
16
|
+
"COS_DURABLE_QUERY_JOBS"
|
|
17
|
+
],
|
|
18
|
+
"maintenance": {
|
|
19
|
+
"scope": "cross_boot",
|
|
20
|
+
"adoptionRequired": true,
|
|
21
|
+
"networkRestartEndpoint": false
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.14.0",
|
|
4
|
+
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"glasses-server": "bin/cli.cjs"
|
|
7
|
+
"glasses-server": "bin/cli.cjs",
|
|
8
|
+
"glasses-server-managed": "bin/managed-server.cjs"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"start": "node bin/cli.cjs",
|
|
@@ -24,6 +25,8 @@
|
|
|
24
25
|
],
|
|
25
26
|
"files": [
|
|
26
27
|
"bin/cli.cjs",
|
|
28
|
+
"bin/managed-server.cjs",
|
|
29
|
+
"managed-runtime-contract.json",
|
|
27
30
|
"server",
|
|
28
31
|
"shared",
|
|
29
32
|
"!server/**/*.test.ts",
|
package/server/index.ts
CHANGED
|
@@ -28,6 +28,14 @@ import { sessionsRouter } from './routes/sessions.js'
|
|
|
28
28
|
import { mediaRouter, mediaBodyParser } from './routes/media.js'
|
|
29
29
|
import { promptDraftsRouter } from './routes/prompt-drafts.js'
|
|
30
30
|
import { cliDebugRouter } from './routes/cli-debug.js'
|
|
31
|
+
import { maintenanceRouter } from './routes/maintenance.js'
|
|
32
|
+
import { ttsRouter } from './routes/tts.js'
|
|
33
|
+
import { voiceRouter } from './routes/voice.js'
|
|
34
|
+
import { glossaryRouter } from './routes/glossary.js'
|
|
35
|
+
import { handoffsRouter } from './routes/handoffs.js'
|
|
36
|
+
import { recoveryRouter } from './routes/recovery.js'
|
|
37
|
+
import { promptEditRouter } from './routes/prompt-edit.js'
|
|
38
|
+
import { bookmarksRouter } from './routes/bookmarks.js'
|
|
31
39
|
import { prewarmContext } from './lib/context-builder.js'
|
|
32
40
|
import { preWarmCLI } from './lib/claude-bridge.js'
|
|
33
41
|
import { getCodexRunConfig } from './lib/codex-run-ledger.js'
|
|
@@ -58,6 +66,13 @@ import {
|
|
|
58
66
|
isTailscaleIpv4,
|
|
59
67
|
} from './lib/network-policy.js'
|
|
60
68
|
import { timingSafeTokenEqual } from './lib/token-auth.js'
|
|
69
|
+
import { isManagedRuntime } from './lib/managed-runtime.js'
|
|
70
|
+
import {
|
|
71
|
+
acquireMaintenanceWork,
|
|
72
|
+
MaintenanceLifecycleError,
|
|
73
|
+
maintenanceAdmissionsOpen,
|
|
74
|
+
maintenanceErrorPayload,
|
|
75
|
+
} from './lib/maintenance-lifecycle.js'
|
|
61
76
|
|
|
62
77
|
const app = express()
|
|
63
78
|
const PORT = parseInt(process.env.PORT ?? '3141', 10)
|
|
@@ -74,6 +89,9 @@ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
|
|
|
74
89
|
|
|
75
90
|
// API token — auto-generate if not set so every session is authenticated.
|
|
76
91
|
const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
|
|
92
|
+
if (isManagedRuntime() && API_TOKEN_AUTO) {
|
|
93
|
+
throw new Error('Managed COS startup requires a pre-provisioned COS_API_TOKEN.')
|
|
94
|
+
}
|
|
77
95
|
const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
|
|
78
96
|
process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
|
|
79
97
|
// Persist an auto-generated token to ~/.cos-glasses/.env so it SURVIVES restarts.
|
|
@@ -135,6 +153,42 @@ app.use('/api', (req, res, next) => {
|
|
|
135
153
|
next()
|
|
136
154
|
})
|
|
137
155
|
|
|
156
|
+
// Fail-closed catch-all for mutation routes that do not own a more specific
|
|
157
|
+
// lifecycle lease below. This closes the admission/drain race for secondary
|
|
158
|
+
// state-changing APIs (media, sessions, settings, diagnostics) without
|
|
159
|
+
// double-owning provider and recording continuations whose routes retain work
|
|
160
|
+
// through their true terminal boundary.
|
|
161
|
+
app.use('/api', (req, res, next) => {
|
|
162
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
|
|
163
|
+
const lifecycleOwned = (req.path === '/query-jobs' && req.method === 'POST')
|
|
164
|
+
|| req.path === '/query'
|
|
165
|
+
|| req.path === '/transcribe'
|
|
166
|
+
|| req.path.startsWith('/transcribe-stream')
|
|
167
|
+
|| req.path === '/meeting/save'
|
|
168
|
+
|| req.path.startsWith('/prompt-drafts')
|
|
169
|
+
|| req.path.startsWith('/maintenance/drain')
|
|
170
|
+
if (lifecycleOwned) return next()
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const lease = acquireMaintenanceWork('api_mutation')
|
|
174
|
+
let released = false
|
|
175
|
+
const release = () => {
|
|
176
|
+
if (released) return
|
|
177
|
+
released = true
|
|
178
|
+
lease.release()
|
|
179
|
+
}
|
|
180
|
+
res.once('finish', release)
|
|
181
|
+
res.once('close', release)
|
|
182
|
+
next()
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof MaintenanceLifecycleError) {
|
|
185
|
+
if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
|
|
186
|
+
return res.status(error.status).json(maintenanceErrorPayload(error))
|
|
187
|
+
}
|
|
188
|
+
return res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
138
192
|
// Authenticate before parsing large upload bodies. The 16 MB allowance stays
|
|
139
193
|
// scoped to /api/media; every other route retains the 10 MB ceiling.
|
|
140
194
|
app.use('/api/media', mediaBodyParser)
|
|
@@ -167,6 +221,14 @@ app.use('/api', sessionsRouter)
|
|
|
167
221
|
app.use('/api', mediaRouter)
|
|
168
222
|
app.use('/api', promptDraftsRouter)
|
|
169
223
|
app.use('/api', cliDebugRouter)
|
|
224
|
+
app.use('/api', maintenanceRouter)
|
|
225
|
+
app.use('/api', ttsRouter)
|
|
226
|
+
app.use('/api', voiceRouter)
|
|
227
|
+
app.use('/api', glossaryRouter)
|
|
228
|
+
app.use('/api', handoffsRouter)
|
|
229
|
+
app.use('/api', recoveryRouter)
|
|
230
|
+
app.use('/api', promptEditRouter)
|
|
231
|
+
app.use('/api', bookmarksRouter)
|
|
170
232
|
|
|
171
233
|
// OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
|
|
172
234
|
// Mounted at root — routes are /v1/chat/completions and /v1/models
|
|
@@ -242,6 +304,7 @@ listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP'
|
|
|
242
304
|
|
|
243
305
|
listenRequiredServers(listeners).then(() => {
|
|
244
306
|
const serverInstanceId = initializeServerInstanceId()
|
|
307
|
+
const startupAdmissionsOpen = maintenanceAdmissionsOpen()
|
|
245
308
|
if (listeners.some(listener => listener.label === 'HTTPS')) {
|
|
246
309
|
console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
|
|
247
310
|
}
|
|
@@ -249,17 +312,21 @@ listenRequiredServers(listeners).then(() => {
|
|
|
249
312
|
console.log(`[COS API] Server instance: ${serverInstanceId}`)
|
|
250
313
|
console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
|
|
251
314
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
315
|
+
if (startupAdmissionsOpen) {
|
|
316
|
+
void initQueryJobRuntime().then(health => {
|
|
317
|
+
if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
|
|
318
|
+
console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
|
|
319
|
+
} else {
|
|
320
|
+
console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
|
|
321
|
+
}
|
|
322
|
+
}).catch(error => {
|
|
323
|
+
// The store remains degraded and rejects admission. Legacy /api/query is
|
|
324
|
+
// still mounted, so disabling the feature flag is an immediate rollback.
|
|
325
|
+
console.error('[COS API] Durable query-job store unavailable:', error)
|
|
326
|
+
})
|
|
327
|
+
} else {
|
|
328
|
+
console.log('[COS API] Startup maintenance gate is closed — durable recovery waits for controller adoption')
|
|
329
|
+
}
|
|
263
330
|
|
|
264
331
|
// Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
|
|
265
332
|
// not paste-able — enumerate real interfaces and label the Tailscale one.
|
|
@@ -281,8 +348,10 @@ listenRequiredServers(listeners).then(() => {
|
|
|
281
348
|
}
|
|
282
349
|
} catch { /* interface enumeration is best-effort */ }
|
|
283
350
|
|
|
284
|
-
//
|
|
285
|
-
|
|
351
|
+
// Interactive standalone startup may print a newly generated pairing token.
|
|
352
|
+
// Managed startup never generates or logs credentials; COS Control copies the
|
|
353
|
+
// pre-provisioned token through the local pasteboard flow instead.
|
|
354
|
+
if (API_TOKEN_AUTO && !isManagedRuntime()) {
|
|
286
355
|
console.log('')
|
|
287
356
|
console.log(`[COS API] API Token: ${API_TOKEN}`)
|
|
288
357
|
console.log('[COS API] ^ paste this into the COS Glasses app' + (API_TOKEN_PERSISTED ? ' — saved to ~/.cos-glasses/.env so it stays the same across restarts' : ' (set COS_API_TOKEN in .env for a fixed token)'))
|
|
@@ -306,32 +375,34 @@ listenRequiredServers(listeners).then(() => {
|
|
|
306
375
|
console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
|
|
307
376
|
// Refresh immediately and then periodically. The catalog module retains the
|
|
308
377
|
// last-known-good snapshot if Codex is temporarily unavailable.
|
|
309
|
-
|
|
378
|
+
if (startupAdmissionsOpen) {
|
|
379
|
+
startCodexModelCatalogRefresh()
|
|
310
380
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
381
|
+
if (COS_MODE) {
|
|
382
|
+
initSessionCache()
|
|
383
|
+
// Pre-warm context cache so first query doesn't wait for the pipeline
|
|
384
|
+
prewarmContext()
|
|
385
|
+
}
|
|
386
|
+
// Start local whisper-server (model stays in RAM for ~50ms transcription)
|
|
387
|
+
startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
|
|
388
|
+
// Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
|
|
389
|
+
const embeddingOk = initSpeakerEmbeddings()
|
|
390
|
+
console.log(`[startup] Speaker embeddings: ${embeddingOk ? 'active' : 'disabled (model not found)'}`)
|
|
391
|
+
// Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
|
|
392
|
+
const vadOk = initSileroVAD()
|
|
393
|
+
console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
|
|
324
394
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
395
|
+
// Pre-warm Claude only when installed so Codex-only startup stays quiet.
|
|
396
|
+
if (claudeAvailable) {
|
|
397
|
+
preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
|
|
398
|
+
}
|
|
329
399
|
|
|
330
|
-
|
|
331
|
-
|
|
400
|
+
// Auto-snapshot active sessions every 5 min (survives restarts)
|
|
401
|
+
startAutoSnapshot(5 * 60_000)
|
|
332
402
|
|
|
333
|
-
|
|
334
|
-
|
|
403
|
+
// Durable media GC (staged/reserved expiry + generated-image content TTL).
|
|
404
|
+
getMediaStore().startGC()
|
|
405
|
+
}
|
|
335
406
|
}).catch((error: NodeJS.ErrnoException) => {
|
|
336
407
|
console.error(`[COS API] Fatal listener startup: ${error.message}`)
|
|
337
408
|
process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Bookmarks — save individual messages for quick reference from glasses
|
|
2
|
+
// Stored as a flat JSON array in server/data/bookmarks.json
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
5
|
+
import { resolve, dirname } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const DATA_DIR = resolve(__dirname, '..', 'data')
|
|
11
|
+
const BOOKMARKS_PATH = resolve(DATA_DIR, 'bookmarks.json')
|
|
12
|
+
|
|
13
|
+
// ── Interfaces ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface Bookmark {
|
|
16
|
+
id: number
|
|
17
|
+
query: string // original user query
|
|
18
|
+
text: string // COS response (plain text, markdown stripped)
|
|
19
|
+
label: string // short label for list display
|
|
20
|
+
savedAt: number // when bookmarked (epoch ms)
|
|
21
|
+
originalTimestamp: number // when message was originally received
|
|
22
|
+
messageIndex: number // which message # it was in the session
|
|
23
|
+
attachments?: MediaAttachmentRef[] // Release A — refs only, never bytes
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Read/Write ──────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function ensureDataDir(): void {
|
|
29
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadBookmarks(): Bookmark[] {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(BOOKMARKS_PATH, 'utf-8')
|
|
35
|
+
return JSON.parse(raw) as Bookmark[]
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveBookmarks(bookmarks: Bookmark[]): void {
|
|
42
|
+
ensureDataDir()
|
|
43
|
+
writeFileSync(BOOKMARKS_PATH, JSON.stringify(bookmarks, null, 2))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Operations ──────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** Save a message as a bookmark. Returns the new bookmark. Attachment refs
|
|
49
|
+
* are optional, validated through the strict parser (refs only, no bytes). */
|
|
50
|
+
export function addBookmark(
|
|
51
|
+
query: string,
|
|
52
|
+
text: string,
|
|
53
|
+
messageIndex: number,
|
|
54
|
+
originalTimestamp: number,
|
|
55
|
+
attachments?: unknown,
|
|
56
|
+
): Bookmark {
|
|
57
|
+
const bookmarks = loadBookmarks()
|
|
58
|
+
|
|
59
|
+
// Auto-generate label from query (first 50 chars)
|
|
60
|
+
const label = query.length > 50 ? query.slice(0, 47) + '...' : query
|
|
61
|
+
|
|
62
|
+
// Next ID = max existing + 1
|
|
63
|
+
const nextId = bookmarks.length > 0 ? Math.max(...bookmarks.map(b => b.id)) + 1 : 1
|
|
64
|
+
|
|
65
|
+
const validRefs = parseMediaAttachmentRefs(attachments)
|
|
66
|
+
const bookmark: Bookmark = {
|
|
67
|
+
id: nextId,
|
|
68
|
+
query,
|
|
69
|
+
text,
|
|
70
|
+
label,
|
|
71
|
+
savedAt: Date.now(),
|
|
72
|
+
originalTimestamp,
|
|
73
|
+
messageIndex,
|
|
74
|
+
...(validRefs.length > 0 ? { attachments: validRefs } : {}),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
bookmarks.push(bookmark)
|
|
78
|
+
saveBookmarks(bookmarks)
|
|
79
|
+
return bookmark
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Delete a bookmark by ID. Returns true if found and deleted. */
|
|
83
|
+
export function deleteBookmark(id: number): boolean {
|
|
84
|
+
const bookmarks = loadBookmarks()
|
|
85
|
+
const idx = bookmarks.findIndex(b => b.id === id)
|
|
86
|
+
if (idx === -1) return false
|
|
87
|
+
bookmarks.splice(idx, 1)
|
|
88
|
+
saveBookmarks(bookmarks)
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Get a single bookmark by ID */
|
|
93
|
+
export function getBookmark(id: number): Bookmark | null {
|
|
94
|
+
const bookmarks = loadBookmarks()
|
|
95
|
+
return bookmarks.find(b => b.id === id) ?? null
|
|
96
|
+
}
|