@gotcos/glasses-server 6.37.2 → 6.38.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 +20 -1
- package/CHANGELOG.md +69 -0
- package/bin/cli.cjs +32 -0
- package/package.json +2 -2
- package/server/lib/archive-index.ts +186 -0
- package/server/lib/archive-search.ts +195 -0
- package/server/lib/archive.ts +37 -16
- package/server/lib/unsaved-audio-quarantine.ts +35 -0
- package/server/routes/archive.ts +56 -4
- package/server/routes/meeting.ts +19 -1
package/.env.example
CHANGED
|
@@ -218,7 +218,26 @@ BIND_HOST=0.0.0.0
|
|
|
218
218
|
# 72 covers a long weekend away from the Mac.
|
|
219
219
|
# COS_UNSAVED_AUDIO_RETENTION_HOURS=72
|
|
220
220
|
|
|
221
|
-
# ── LIVE CUES (optional
|
|
221
|
+
# ── LIVE CUES (optional, OFF unless you set it — costs model calls) ───────
|
|
222
|
+
#
|
|
223
|
+
# The closest thing COS has to conversing with you DURING a meeting rather than
|
|
224
|
+
# after it: it reads the running transcript and puts short prompts on the lens —
|
|
225
|
+
# the question you would have wanted to ask, while it is still useful.
|
|
226
|
+
#
|
|
227
|
+
# WHAT IT COSTS, PLAINLY. Every cue is a model call on YOUR agent quota, not a
|
|
228
|
+
# free local pass. It is bounded, but it is not free:
|
|
229
|
+
#
|
|
230
|
+
# at most 8 cue pipelines per meeting MAX_PIPELINES_PER_MEETING
|
|
231
|
+
# at most one start per 60 seconds FLOOR_BETWEEN_STARTS_MS
|
|
232
|
+
# 30s cooldown after a cue fires COOLDOWN_AFTER_CUE_MS
|
|
233
|
+
#
|
|
234
|
+
# So a long meeting can spend up to 8 model calls it would not otherwise make,
|
|
235
|
+
# and each one runs the full planner -> memory -> insight chain below. On a
|
|
236
|
+
# metered plan, leave this off. It IS off unless you set it.
|
|
237
|
+
#
|
|
238
|
+
# Runs on Cursor Composer. That is the only supported model in v1 — any other
|
|
239
|
+
# value fails closed rather than quietly billing a model you did not choose.
|
|
240
|
+
#
|
|
222
241
|
# Live meeting coaching cues on the lens: transcript window -> Composer
|
|
223
242
|
# planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
|
|
224
243
|
# Requires COS_SCRIPTS_DIR (memory hops) plus the Cursor Agent CLI.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
+
## 6.38.0
|
|
2
|
+
|
|
3
|
+
Six months of archived conversation you can finally search.
|
|
4
|
+
|
|
5
|
+
NEW: GET /api/archive/search?q=&from=&to=&limit=. The archive already held every
|
|
6
|
+
day's conversations -- 175 day files spanning six months on a real install -- with
|
|
7
|
+
no way to find anything in them.
|
|
8
|
+
|
|
9
|
+
The scan never calls JSON.parse. Day sizes are wildly skewed: the median day is
|
|
10
|
+
36 KB, but the largest measured is 343 MB of agent transcript, and materialising
|
|
11
|
+
that one day costs 1.2 GB heap / 2.3 GB RSS. On a process that also runs the
|
|
12
|
+
wearer's live session that is not affordable, so days are scanned as raw bytes
|
|
13
|
+
through a stream. A hit is therefore attributed to a DATE plus surrounding text,
|
|
14
|
+
not to a chat; open /archive/:date/chats for structure. A full 90-day scan
|
|
15
|
+
measures at 2.4 s, so there is no index to build and nothing that can drift.
|
|
16
|
+
|
|
17
|
+
FIXED: GET /api/archive no longer parses 1.2 GB to list days. It built {date,
|
|
18
|
+
summary, chatCount, exchangeCount} by parsing EVERY day file and discarding the
|
|
19
|
+
bodies -- one request away from a multi-gigabyte spike. It now reads a sidecar
|
|
20
|
+
index keyed by each day's (size, mtimeMs). Measured on the real corpus: cold build
|
|
21
|
+
2,208 ms at 248 MB RSS for all 175 days, warm read 1 ms. Both halves of the key
|
|
22
|
+
matter -- size alone misses an in-place edit that preserves length.
|
|
23
|
+
|
|
24
|
+
listArchiveDates() is deleted rather than deprecated. It had one caller, and an
|
|
25
|
+
uncalled landmine is still a landmine.
|
|
26
|
+
|
|
27
|
+
Route order is load-bearing: /archive/search is registered ABOVE /archive/:date,
|
|
28
|
+
because Express matches in order and the date validator would otherwise reject it
|
|
29
|
+
as :date === "search". That is the same trap that has always made
|
|
30
|
+
/api/archive/dates look like an empty archive. Older servers still exhibit it, so
|
|
31
|
+
clients should read a 400 "Invalid date" from the search path as "route absent".
|
|
32
|
+
|
|
33
|
+
## 6.37.3
|
|
34
|
+
|
|
35
|
+
Speaker ID works out of the box. A silent capture stops retrying forever. Live
|
|
36
|
+
Cues says what it costs.
|
|
37
|
+
|
|
38
|
+
SPEAKER MODEL IN NORMAL SETUP. 6.37.2 added `--setup-speaker-model` as a command
|
|
39
|
+
you had to know about. Now the voiceprint model is fetched during ordinary setup,
|
|
40
|
+
with `SKIP_SPEAKER_MODEL_DOWNLOAD=1` as the escape hatch — the same shape the
|
|
41
|
+
whisper models already use, and those are 1.5 GB, sixty times larger.
|
|
42
|
+
|
|
43
|
+
Deliberately NOT a lazy fetch on first use. Lazy would fire the download at the
|
|
44
|
+
START OF A MEETING: 26 MB on hotel wifi degrading the exact session it exists to
|
|
45
|
+
improve, and a GitHub release asset promoted from an install-time dependency to a
|
|
46
|
+
runtime one. A failed fetch is non-fatal — diarization is opt-in by design, so
|
|
47
|
+
the server falls back to wearer/Ext rather than refusing to start.
|
|
48
|
+
|
|
49
|
+
A CAPTURE WITH NO SPEECH IS RECOVERED, NOT FAILED. The recover route threw
|
|
50
|
+
`recovery produced an empty transcript` when whisper returned nothing, which left
|
|
51
|
+
the capture in the unsaved list and retried it on every boot and every button
|
|
52
|
+
press. Measured on this machine: one 33-second capture failed that way 1,131
|
|
53
|
+
times, alternating in the log with the auto-recover path claiming success for the
|
|
54
|
+
same session, while the panel showed only "1 recoverable" and the error went to
|
|
55
|
+
stderr where nobody looks. The user presses Recover and nothing happens, because
|
|
56
|
+
nothing can.
|
|
57
|
+
|
|
58
|
+
Silence now writes a receipt with `outcome: 'no_speech'` and clears. The audio is
|
|
59
|
+
NOT deleted — it leaves on the ordinary retention clock, so a capture wrongly
|
|
60
|
+
judged silent by a bad decode is still on disk for its full window.
|
|
61
|
+
|
|
62
|
+
LIVE CUES NOW STATES ITS COST. The flag was already documented as a master
|
|
63
|
+
switch; what was missing was that every cue is a model call on your own agent
|
|
64
|
+
quota. Now stated with the real bounds — at most 8 pipelines per meeting, one
|
|
65
|
+
start per 60s, 30s cooldown — and the real model, Cursor Composer, which is the
|
|
66
|
+
only supported value in v1.
|
|
67
|
+
|
|
68
|
+
223 files, 3117 tests.
|
|
69
|
+
|
|
1
70
|
## 6.37.2
|
|
2
71
|
|
|
3
72
|
`--setup-speaker-model` — one command to install the voiceprint model.
|
package/bin/cli.cjs
CHANGED
|
@@ -706,6 +706,38 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
|
706
706
|
}
|
|
707
707
|
}
|
|
708
708
|
|
|
709
|
+
// Step 5b: the voiceprint model — named per-speaker diarization.
|
|
710
|
+
//
|
|
711
|
+
// Folded into normal setup rather than fetched lazily on first use. Lazy would
|
|
712
|
+
// fire the download at the START OF A MEETING, which is the worst possible
|
|
713
|
+
// moment: 26 MB on hotel wifi degrades the exact session it exists to improve,
|
|
714
|
+
// and it makes a GitHub release asset a runtime dependency instead of an
|
|
715
|
+
// install-time one.
|
|
716
|
+
//
|
|
717
|
+
// This follows the precedent already set by the whisper models, which are 1.5 GB
|
|
718
|
+
// -- sixty times larger -- downloaded here with SKIP_WHISPER_DOWNLOAD as the
|
|
719
|
+
// escape hatch. Same shape, same opt-out, so nobody on a metered or restricted
|
|
720
|
+
// network is forced into it.
|
|
721
|
+
if (!SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
|
|
722
|
+
const spkDest = join(CONFIG_DIR, 'models', SPEAKER_MODEL.filename)
|
|
723
|
+
if (existsSync(spkDest) && sha256File(spkDest) === SPEAKER_MODEL.sha256) {
|
|
724
|
+
console.log(green(' ✓') + ' Voiceprint model ready ' + dim('— named speakers available'))
|
|
725
|
+
} else {
|
|
726
|
+
console.log(' ' + dim('Fetching the voiceprint model (~26 MB) for named speakers.'))
|
|
727
|
+
console.log(' ' + dim('Skip: SKIP_SPEAKER_MODEL_DOWNLOAD=1 npx --yes @gotcos/glasses-server@latest'))
|
|
728
|
+
const spkCode = setupSpeakerModel()
|
|
729
|
+
if (spkCode !== 0) {
|
|
730
|
+
// NOT fatal. Diarization is opt-in by design: without the model the server
|
|
731
|
+
// stays on wearer/Ext fallback rather than failing, so a failed fetch must
|
|
732
|
+
// not block a working install.
|
|
733
|
+
console.log(yellow(' ⚠') + ' Voiceprint model unavailable ' + dim('— speakers stay wearer/Ext'))
|
|
734
|
+
console.log(' ' + dim('Retry later: npx --yes @gotcos/glasses-server@latest --setup-speaker-model'))
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} else if (process.env.SKIP_SPEAKER_MODEL_DOWNLOAD === '1') {
|
|
738
|
+
console.log(yellow(' ⚠') + ' SKIP_SPEAKER_MODEL_DOWNLOAD=1 — named speakers unavailable')
|
|
739
|
+
}
|
|
740
|
+
|
|
709
741
|
if (PREPARE_ONLY && SETUP_TRANSCRIPTION) {
|
|
710
742
|
console.log('')
|
|
711
743
|
if (transcriptionSetupFailures.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.38.0",
|
|
4
|
+
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// A sidecar index of per-day archive summaries.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. `listArchiveDates()` produces {date, summary, chatCount,
|
|
4
|
+
// exchangeCount} by JSON.parsing EVERY day file and discarding the bodies. On the
|
|
5
|
+
// real corpus that is 175 files / 1.2 GB, and the single largest day (2026-07-30,
|
|
6
|
+
// 343 MB) measures at 1.2 GB heap / 2.3 GB RSS to materialise on its own. That is
|
|
7
|
+
// one GET away from a multi-gigabyte spike on the same process that runs the
|
|
8
|
+
// wearer's live glasses session.
|
|
9
|
+
//
|
|
10
|
+
// The index holds exactly the fields that listing needs, keyed by the day's own
|
|
11
|
+
// (size, mtimeMs). A day whose bytes have not changed is never reopened, so the
|
|
12
|
+
// steady-state cost of a listing is one readdir plus one small JSON read.
|
|
13
|
+
//
|
|
14
|
+
// INVALIDATION IS THE WHOLE CONTRACT. size+mtimeMs is what makes this honest: an
|
|
15
|
+
// archive that is appended to changes both, so a stale entry cannot survive a
|
|
16
|
+
// write. Entries for vanished days are dropped rather than kept, because a listing
|
|
17
|
+
// that names a file nobody can open is worse than one that omits it.
|
|
18
|
+
import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
19
|
+
import { resolve } from 'node:path'
|
|
20
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
21
|
+
|
|
22
|
+
export const ARCHIVE_INDEX_SCHEMA = 1
|
|
23
|
+
|
|
24
|
+
export interface ArchiveIndexEntry {
|
|
25
|
+
date: string
|
|
26
|
+
size: number
|
|
27
|
+
mtimeMs: number
|
|
28
|
+
chatCount: number
|
|
29
|
+
exchangeCount: number
|
|
30
|
+
summary: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ArchiveIndexFile {
|
|
34
|
+
schemaVersion: number
|
|
35
|
+
entries: Record<string, ArchiveIndexEntry>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
39
|
+
/** Chats carry exactly one `exchangeCount` each and the key appears nowhere else
|
|
40
|
+
* in the shape. Verified against parsed truth on six real days (8/28/93/47/11/90
|
|
41
|
+
* chats) before this was relied on. */
|
|
42
|
+
const EXCHANGE_COUNT_RE = /"exchangeCount":\s*(\d+)/g
|
|
43
|
+
const CARRY_CHARS = 64
|
|
44
|
+
|
|
45
|
+
export function emptyIndex(): ArchiveIndexFile {
|
|
46
|
+
return { schemaVersion: ARCHIVE_INDEX_SCHEMA, entries: {} }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readIndexFile(indexPath: string): ArchiveIndexFile {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(indexPath)) return emptyIndex()
|
|
52
|
+
const parsed = JSON.parse(readFileSync(indexPath, 'utf8')) as ArchiveIndexFile
|
|
53
|
+
// A schema bump invalidates wholesale rather than trying to migrate: the index
|
|
54
|
+
// is a cache, and rebuilding it costs one scan.
|
|
55
|
+
if (parsed?.schemaVersion !== ARCHIVE_INDEX_SCHEMA || typeof parsed.entries !== 'object') {
|
|
56
|
+
return emptyIndex()
|
|
57
|
+
}
|
|
58
|
+
return { schemaVersion: ARCHIVE_INDEX_SCHEMA, entries: parsed.entries ?? {} }
|
|
59
|
+
} catch {
|
|
60
|
+
return emptyIndex() // a corrupt cache must never take the listing down
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pull the top-level `summary` string without materialising the day. It sits in
|
|
65
|
+
* the opening object, so the first chunk is enough; a day that hides it deeper
|
|
66
|
+
* simply reports null rather than costing a full read. */
|
|
67
|
+
export function extractSummary(head: string): string | null {
|
|
68
|
+
const m = /"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(head)
|
|
69
|
+
if (!m) return null
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(`"${m[1]}"`) as string
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Stream one day and count chats + exchanges. Never parses. */
|
|
78
|
+
export async function summariseDayFile(filePath: string): Promise<{ chatCount: number; exchangeCount: number; summary: string | null }> {
|
|
79
|
+
let chatCount = 0
|
|
80
|
+
let exchangeCount = 0
|
|
81
|
+
let summary: string | null = null
|
|
82
|
+
let carry = ''
|
|
83
|
+
let first = true
|
|
84
|
+
|
|
85
|
+
await new Promise<void>((done, fail) => {
|
|
86
|
+
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: 1 << 20 })
|
|
87
|
+
stream.on('data', (chunk: string | Buffer) => {
|
|
88
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
89
|
+
const hay = carry + text
|
|
90
|
+
if (first) {
|
|
91
|
+
summary = extractSummary(hay)
|
|
92
|
+
first = false
|
|
93
|
+
}
|
|
94
|
+
EXCHANGE_COUNT_RE.lastIndex = 0
|
|
95
|
+
let m: RegExpExecArray | null
|
|
96
|
+
while ((m = EXCHANGE_COUNT_RE.exec(hay)) !== null) {
|
|
97
|
+
chatCount++
|
|
98
|
+
exchangeCount += Number(m[1])
|
|
99
|
+
}
|
|
100
|
+
// Carry enough to reassemble a key/value split across the boundary. Matches
|
|
101
|
+
// inside the carry are not double-counted because the carry is short and the
|
|
102
|
+
// next pass starts from it, not from the whole previous chunk.
|
|
103
|
+
carry = hay.slice(Math.max(0, hay.length - CARRY_CHARS))
|
|
104
|
+
})
|
|
105
|
+
stream.on('error', fail)
|
|
106
|
+
stream.on('end', () => done())
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return { chatCount, exchangeCount, summary }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RefreshResult {
|
|
113
|
+
entries: ArchiveIndexEntry[]
|
|
114
|
+
rebuilt: string[]
|
|
115
|
+
dropped: string[]
|
|
116
|
+
fromCache: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bring the index in line with what is on disk and return the listing, newest
|
|
121
|
+
* first. Only days whose (size, mtimeMs) changed are reopened.
|
|
122
|
+
*/
|
|
123
|
+
export async function refreshArchiveIndex(dir: string, indexPath: string): Promise<RefreshResult> {
|
|
124
|
+
const file = readIndexFile(indexPath)
|
|
125
|
+
const rebuilt: string[] = []
|
|
126
|
+
const dropped: string[] = []
|
|
127
|
+
let fromCache = 0
|
|
128
|
+
|
|
129
|
+
let present: string[] = []
|
|
130
|
+
try {
|
|
131
|
+
present = readdirSync(dir)
|
|
132
|
+
.filter(f => f.endsWith('.json'))
|
|
133
|
+
.map(f => f.slice(0, -'.json'.length))
|
|
134
|
+
.filter(d => DATE_RE.test(d))
|
|
135
|
+
} catch {
|
|
136
|
+
return { entries: [], rebuilt, dropped, fromCache }
|
|
137
|
+
}
|
|
138
|
+
const presentSet = new Set(present)
|
|
139
|
+
|
|
140
|
+
for (const date of Object.keys(file.entries)) {
|
|
141
|
+
if (!presentSet.has(date)) {
|
|
142
|
+
delete file.entries[date]
|
|
143
|
+
dropped.push(date)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const date of present) {
|
|
148
|
+
const filePath = resolve(dir, `${date}.json`)
|
|
149
|
+
let st
|
|
150
|
+
try {
|
|
151
|
+
st = statSync(filePath)
|
|
152
|
+
} catch {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const cached = file.entries[date]
|
|
156
|
+
if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) {
|
|
157
|
+
fromCache++
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const s = await summariseDayFile(filePath)
|
|
162
|
+
file.entries[date] = {
|
|
163
|
+
date,
|
|
164
|
+
size: st.size,
|
|
165
|
+
mtimeMs: st.mtimeMs,
|
|
166
|
+
chatCount: s.chatCount,
|
|
167
|
+
exchangeCount: s.exchangeCount,
|
|
168
|
+
summary: s.summary,
|
|
169
|
+
}
|
|
170
|
+
rebuilt.push(date)
|
|
171
|
+
} catch {
|
|
172
|
+
continue // an unreadable day must not abort the listing
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (rebuilt.length > 0 || dropped.length > 0) {
|
|
177
|
+
try {
|
|
178
|
+
atomicWriteFileSync(indexPath, `${JSON.stringify(file)}\n`, { mode: 0o600 })
|
|
179
|
+
} catch {
|
|
180
|
+
// A cache that cannot persist still serves this call correctly.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const entries = Object.values(file.entries).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
|
185
|
+
return { entries, rebuilt, dropped, fromCache }
|
|
186
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Literal text search across the daily conversation archive.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS NEVER CALLS JSON.parse. The archive is 175 day files spanning six
|
|
4
|
+
// months, and their sizes are wildly skewed: the median day is 36 KB but
|
|
5
|
+
// 2026-07-30 is 327 MB of agent transcript (283,356 exchanges). Parsing that
|
|
6
|
+
// into JS objects would cost multiple GB of heap on a server that also runs the
|
|
7
|
+
// wearer's live glasses session. A day file is therefore scanned as raw bytes
|
|
8
|
+
// and never materialised.
|
|
9
|
+
//
|
|
10
|
+
// The cost of that choice is attribution: a hit reports its DATE and a text
|
|
11
|
+
// snippet, not a chat index. Callers open the day through the existing
|
|
12
|
+
// /archive/:date/chats routes for structure. Day-level attribution is what a
|
|
13
|
+
// stream can give safely, and the existing routes already cover the rest.
|
|
14
|
+
//
|
|
15
|
+
// Measured on the real corpus: a full literal scan of a 90-day window is ~2.4s,
|
|
16
|
+
// so this needs no index, no background build, and nothing that can drift out of
|
|
17
|
+
// sync with the archive itself.
|
|
18
|
+
import { createReadStream } from 'node:fs'
|
|
19
|
+
import { stat } from 'node:fs/promises'
|
|
20
|
+
import { resolve } from 'node:path'
|
|
21
|
+
|
|
22
|
+
export const MIN_QUERY_CHARS = 2
|
|
23
|
+
export const DEFAULT_LIMIT = 50
|
|
24
|
+
export const MAX_LIMIT = 200
|
|
25
|
+
export const SNIPPET_RADIUS = 120
|
|
26
|
+
/** Per-day snippet cap. A term appearing 40,000 times in one 327 MB day must not
|
|
27
|
+
* return 40,000 snippets; the count still reports the true total. */
|
|
28
|
+
export const MAX_SNIPPETS_PER_DAY = 3
|
|
29
|
+
const CHUNK_BYTES = 1 << 20
|
|
30
|
+
|
|
31
|
+
export interface ArchiveSearchHit {
|
|
32
|
+
date: string
|
|
33
|
+
matches: number
|
|
34
|
+
snippets: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ArchiveSearchResult {
|
|
38
|
+
hits: ArchiveSearchHit[]
|
|
39
|
+
scannedDays: number
|
|
40
|
+
bytesScanned: number
|
|
41
|
+
truncated: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** YYYY-MM-DD, the archive's own filename contract. Lexical order IS date order,
|
|
45
|
+
* which is what makes the range filter a string compare. */
|
|
46
|
+
export function isArchiveDate(value: unknown): value is string {
|
|
47
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function datesInRange(dates: string[], from?: string, to?: string): string[] {
|
|
51
|
+
return dates
|
|
52
|
+
.filter(isArchiveDate)
|
|
53
|
+
.filter(d => (from ? d >= from : true) && (to ? d <= to : true))
|
|
54
|
+
.sort()
|
|
55
|
+
.reverse()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cleanSnippet(raw: string): string {
|
|
59
|
+
// The scan runs over JSON source, so a snippet arrives carrying escape
|
|
60
|
+
// sequences and structural punctuation. Unescape the common ones and collapse
|
|
61
|
+
// whitespace so the caller can render it as prose.
|
|
62
|
+
return raw
|
|
63
|
+
.replace(/\\n/g, ' ')
|
|
64
|
+
.replace(/\\t/g, ' ')
|
|
65
|
+
.replace(/\\"/g, '"')
|
|
66
|
+
.replace(/\\\\/g, '\\')
|
|
67
|
+
.replace(/\s+/g, ' ')
|
|
68
|
+
.trim()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Count matches in `hayLower` but cut snippets from `hayOriginal`.
|
|
73
|
+
*
|
|
74
|
+
* Matching has to happen on the lowercased text for case-insensitivity, but a
|
|
75
|
+
* snippet cut from that text renders as all-lowercase prose -- "chelsie owes the
|
|
76
|
+
* sales-vs-education split" -- which reads like broken data in a UI. The offsets
|
|
77
|
+
* line up because toLowerCase() is length-preserving for effectively all real
|
|
78
|
+
* archive text; where it is NOT (a handful of exotic codepoints expand), the
|
|
79
|
+
* length check catches the drift and falls back to the lowered slice rather than
|
|
80
|
+
* cutting at a wrong offset.
|
|
81
|
+
*/
|
|
82
|
+
function collectSnippets(
|
|
83
|
+
hayLower: string,
|
|
84
|
+
hayOriginal: string,
|
|
85
|
+
needle: string,
|
|
86
|
+
out: string[],
|
|
87
|
+
cap: number,
|
|
88
|
+
): number {
|
|
89
|
+
const aligned = hayLower.length === hayOriginal.length
|
|
90
|
+
let found = 0
|
|
91
|
+
let i = hayLower.indexOf(needle)
|
|
92
|
+
while (i !== -1) {
|
|
93
|
+
found++
|
|
94
|
+
if (out.length < cap) {
|
|
95
|
+
const start = Math.max(0, i - SNIPPET_RADIUS)
|
|
96
|
+
const end = Math.min(hayLower.length, i + needle.length + SNIPPET_RADIUS)
|
|
97
|
+
const snippet = cleanSnippet((aligned ? hayOriginal : hayLower).slice(start, end))
|
|
98
|
+
if (snippet) out.push(snippet)
|
|
99
|
+
}
|
|
100
|
+
i = hayLower.indexOf(needle, i + needle.length)
|
|
101
|
+
}
|
|
102
|
+
return found
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scan ONE day file. Returns null when the term never appears.
|
|
107
|
+
*
|
|
108
|
+
* The overlap is the whole reason this is a separate function with its own test.
|
|
109
|
+
* A 1 MB chunk boundary can fall in the middle of the search term, and a naive
|
|
110
|
+
* per-chunk indexOf silently misses it — the failure mode is a search that works
|
|
111
|
+
* in every test fixture and quietly loses hits on real multi-megabyte days.
|
|
112
|
+
* Each chunk is therefore prefixed with the last (needle.length - 1) characters
|
|
113
|
+
* of the previous one, and matches inside that carried prefix are not counted
|
|
114
|
+
* twice because the search resumes past the needle.
|
|
115
|
+
*/
|
|
116
|
+
export async function searchArchiveFile(
|
|
117
|
+
filePath: string,
|
|
118
|
+
needleLower: string,
|
|
119
|
+
): Promise<{ matches: number; snippets: string[]; bytes: number } | null> {
|
|
120
|
+
let matches = 0
|
|
121
|
+
const snippets: string[] = []
|
|
122
|
+
let bytes = 0
|
|
123
|
+
let carry = ''
|
|
124
|
+
const overlap = Math.max(0, needleLower.length - 1)
|
|
125
|
+
|
|
126
|
+
await new Promise<void>((resolveDone, rejectDone) => {
|
|
127
|
+
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: CHUNK_BYTES })
|
|
128
|
+
stream.on('data', (chunk: string | Buffer) => {
|
|
129
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
130
|
+
bytes += Buffer.byteLength(text, 'utf8')
|
|
131
|
+
const hay = carry + text
|
|
132
|
+
matches += collectSnippets(hay.toLowerCase(), hay, needleLower, snippets, MAX_SNIPPETS_PER_DAY)
|
|
133
|
+
carry = hay.slice(Math.max(0, hay.length - overlap))
|
|
134
|
+
})
|
|
135
|
+
stream.on('error', rejectDone)
|
|
136
|
+
stream.on('end', () => resolveDone())
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return matches > 0 ? { matches, snippets, bytes } : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Search a set of archive days, newest first, stopping once `limit` days have
|
|
144
|
+
* matched. Days are scanned in order so an early-exit favours recent history,
|
|
145
|
+
* which is what a person browsing "what did I say about X" actually wants.
|
|
146
|
+
*/
|
|
147
|
+
export async function searchArchive(opts: {
|
|
148
|
+
dir: string
|
|
149
|
+
dates: string[]
|
|
150
|
+
query: string
|
|
151
|
+
from?: string
|
|
152
|
+
to?: string
|
|
153
|
+
limit?: number
|
|
154
|
+
}): Promise<ArchiveSearchResult> {
|
|
155
|
+
const query = (opts.query ?? '').trim()
|
|
156
|
+
if (query.length < MIN_QUERY_CHARS) {
|
|
157
|
+
throw new Error(`query must be at least ${MIN_QUERY_CHARS} characters`)
|
|
158
|
+
}
|
|
159
|
+
const limit = Math.min(Math.max(1, opts.limit ?? DEFAULT_LIMIT), MAX_LIMIT)
|
|
160
|
+
const needleLower = query.toLowerCase()
|
|
161
|
+
const candidates = datesInRange(opts.dates, opts.from, opts.to)
|
|
162
|
+
|
|
163
|
+
const hits: ArchiveSearchHit[] = []
|
|
164
|
+
let scannedDays = 0
|
|
165
|
+
let bytesScanned = 0
|
|
166
|
+
let truncated = false
|
|
167
|
+
|
|
168
|
+
for (const date of candidates) {
|
|
169
|
+
if (hits.length >= limit) {
|
|
170
|
+
truncated = true
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
const filePath = resolve(opts.dir, `${date}.json`)
|
|
174
|
+
try {
|
|
175
|
+
await stat(filePath)
|
|
176
|
+
} catch {
|
|
177
|
+
continue // listed but absent: a rotation mid-scan is not an error
|
|
178
|
+
}
|
|
179
|
+
scannedDays++
|
|
180
|
+
try {
|
|
181
|
+
const found = await searchArchiveFile(filePath, needleLower)
|
|
182
|
+
if (found) {
|
|
183
|
+
bytesScanned += found.bytes
|
|
184
|
+
hits.push({ date, matches: found.matches, snippets: found.snippets })
|
|
185
|
+
} else {
|
|
186
|
+
// still counts toward bytes so callers can report honest scan cost
|
|
187
|
+
bytesScanned += (await stat(filePath)).size
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
continue // an unreadable day must not abort the whole search
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { hits, scannedDays, bytesScanned, truncated }
|
|
195
|
+
}
|
package/server/lib/archive.ts
CHANGED
|
@@ -94,7 +94,7 @@ export function loadArchive(date: string): DailyArchive | null {
|
|
|
94
94
|
}
|
|
95
95
|
if (result.status === 'missing') return null
|
|
96
96
|
// Defense: a valid-JSON but wrong-shape day file (no chats[]) would make the
|
|
97
|
-
// readers throw 500 AND drop
|
|
97
|
+
// readers throw 500 AND drop the archive listing into its catch → the whole
|
|
98
98
|
// Message History list vanishes on one bad file. Coerce to an empty day.
|
|
99
99
|
const data = result.data
|
|
100
100
|
if (data && !Array.isArray(data.chats)) data.chats = []
|
|
@@ -367,30 +367,51 @@ export async function appendToArchive(
|
|
|
367
367
|
// ── Query functions ─────────────────────────────────────────
|
|
368
368
|
|
|
369
369
|
/** List all archive dates with summaries */
|
|
370
|
-
|
|
370
|
+
/** Sidecar index location. Deliberately a SIBLING of the archive directory, not
|
|
371
|
+
* a file inside it: anything living in that directory has to be excluded by
|
|
372
|
+
* every readdir filter forever, and one missed filter turns the cache into a
|
|
373
|
+
* phantom "day". */
|
|
374
|
+
export function archiveIndexPath(): string {
|
|
375
|
+
return dataPath('archive-index.json')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The archive directory, for readers that must NOT materialise a day file. */
|
|
379
|
+
export function archiveDir(): string {
|
|
380
|
+
return ensureArchiveDir()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Date strings only, straight from readdir — no file is opened.
|
|
385
|
+
*
|
|
386
|
+
* The richer per-day summaries (chat and exchange counts) come from the sidecar
|
|
387
|
+
* index in archive-index.ts. The listing this replaced reached them by
|
|
388
|
+
* JSON.parsing EVERY day file. Measured on the real corpus that is 1.2 GB across
|
|
389
|
+
* 175 files, and the single largest day (2026-07-30, 343 MB) costs 1.2 GB heap /
|
|
390
|
+
* 2.3 GB RSS to parse on its own. Anything that only needs to know WHICH days
|
|
391
|
+
* exist must use this instead, on a server that is also running the wearer's live
|
|
392
|
+
* session.
|
|
393
|
+
*/
|
|
394
|
+
export function listArchiveDateStrings(): string[] {
|
|
371
395
|
try {
|
|
372
396
|
ensureArchiveDir()
|
|
373
|
-
|
|
397
|
+
return readdirSync(ARCHIVE_DIR)
|
|
374
398
|
.filter(f => f.endsWith('.json'))
|
|
399
|
+
.map(f => f.slice(0, -'.json'.length))
|
|
400
|
+
.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
|
|
375
401
|
.sort()
|
|
376
|
-
.reverse()
|
|
377
|
-
|
|
378
|
-
return files.map(f => {
|
|
379
|
-
const date = f.replace('.json', '')
|
|
380
|
-
const archive = loadArchive(date)
|
|
381
|
-
if (!archive) return null
|
|
382
|
-
return {
|
|
383
|
-
date: archive.date,
|
|
384
|
-
summary: archive.summary,
|
|
385
|
-
chatCount: archive.chats.length,
|
|
386
|
-
exchangeCount: archive.chats.reduce((sum, c) => sum + c.exchangeCount, 0),
|
|
387
|
-
}
|
|
388
|
-
}).filter(Boolean) as ArchiveDateSummary[]
|
|
402
|
+
.reverse()
|
|
389
403
|
} catch {
|
|
390
404
|
return []
|
|
391
405
|
}
|
|
392
406
|
}
|
|
393
407
|
|
|
408
|
+
// listArchiveDates() REMOVED (2026-08-26). It produced its summaries by
|
|
409
|
+
// JSON.parsing every day file -- 175 files / 1.2 GB on the real corpus, and 1.2 GB
|
|
410
|
+
// heap / 2.3 GB RSS for the single largest day alone -- on the process that also
|
|
411
|
+
// runs the wearer's live session. The /api/archive route now reads the sidecar
|
|
412
|
+
// index (archive-index.ts) instead. Deleted rather than deprecated: an uncalled
|
|
413
|
+
// landmine is still a landmine, and the next caller would not know.
|
|
414
|
+
|
|
394
415
|
/** Get chat summaries for a specific day */
|
|
395
416
|
export function getArchiveChats(date: string): ArchiveChatSummary[] {
|
|
396
417
|
const archive = loadArchive(date)
|
|
@@ -303,6 +303,41 @@ export function isRecoveryActive(dirName: string): boolean {
|
|
|
303
303
|
return activeRecoveries.has(dirName)
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* A capture that held no speech is RECOVERED, not failed.
|
|
308
|
+
*
|
|
309
|
+
* The recover route used to throw `recovery produced an empty transcript` when
|
|
310
|
+
* whisper returned nothing, which left the capture in the unsaved list and
|
|
311
|
+
* retried it on every boot and every button press. Measured 2026-08-25: one
|
|
312
|
+
* 33-second capture failed this way 1,131 times, alternating with the
|
|
313
|
+
* auto-recover path claiming success, while the panel said only "1 recoverable"
|
|
314
|
+
* and the error went to stderr where nobody looks.
|
|
315
|
+
*
|
|
316
|
+
* Silence is a legitimate outcome. There is nothing to save and nothing to
|
|
317
|
+
* retry, so the capture is receipted and clears -- with `outcome: 'no_speech'`
|
|
318
|
+
* so the distinction stays auditable rather than looking like a normal save.
|
|
319
|
+
*
|
|
320
|
+
* The audio is NOT deleted here. It leaves on the ordinary retention clock, so a
|
|
321
|
+
* capture wrongly judged silent (a bad decode, a broken model) is still on disk
|
|
322
|
+
* for its full window.
|
|
323
|
+
*/
|
|
324
|
+
export function markRecoveredNoSpeech(dirPath: string, chunkFiles: number): void {
|
|
325
|
+
try {
|
|
326
|
+
writeFileSync(
|
|
327
|
+
resolve(dirPath, RECOVERED_RECEIPT),
|
|
328
|
+
`${JSON.stringify({
|
|
329
|
+
schemaVersion: 1,
|
|
330
|
+
recoveredAt: new Date().toISOString(),
|
|
331
|
+
outcome: 'no_speech',
|
|
332
|
+
chunkFiles,
|
|
333
|
+
words: 0,
|
|
334
|
+
note: 'Transcription produced no words. Audio retained until the retention clock clears it.',
|
|
335
|
+
})}\n`,
|
|
336
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
337
|
+
)
|
|
338
|
+
} catch { /* receipt is best-effort; findBySessionId remains the true guard */ }
|
|
339
|
+
}
|
|
340
|
+
|
|
306
341
|
export function markRecovered(dirPath: string, savedFilename: string): void {
|
|
307
342
|
try {
|
|
308
343
|
writeFileSync(
|
package/server/routes/archive.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Archive endpoints — daily conversation archive for glasses history browser
|
|
2
2
|
import { Router } from 'express'
|
|
3
|
-
import {
|
|
3
|
+
import { listArchiveDateStrings, archiveDir, archiveIndexPath, loadArchive, getArchiveChats, getArchiveDayMessages, appendToArchive } from '../lib/archive.js'
|
|
4
4
|
import { getArchiveChatMessagesNumbered } from './message-ref.js'
|
|
5
|
+
import { searchArchive, MAX_LIMIT, DEFAULT_LIMIT } from '../lib/archive-search.js'
|
|
6
|
+
import { refreshArchiveIndex } from '../lib/archive-index.js'
|
|
5
7
|
import { getActiveSessions } from '../lib/conversation.js'
|
|
6
8
|
|
|
7
9
|
export const archiveRouter = Router()
|
|
@@ -21,9 +23,19 @@ archiveRouter.param('date', (req, res, next, date) => {
|
|
|
21
23
|
})
|
|
22
24
|
|
|
23
25
|
// GET /api/archive — list all archive dates with summaries
|
|
24
|
-
archiveRouter.get('/archive', (_req, res) => {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
archiveRouter.get('/archive', async (_req, res) => {
|
|
27
|
+
// Index-backed. The previous implementation parsed every day file to reach four
|
|
28
|
+
// summary fields; see archive-index.ts for the measurements that killed it.
|
|
29
|
+
const { entries, rebuilt, fromCache } = await refreshArchiveIndex(archiveDir(), archiveIndexPath())
|
|
30
|
+
res.json({
|
|
31
|
+
archives: entries.map(e => ({
|
|
32
|
+
date: e.date,
|
|
33
|
+
summary: e.summary,
|
|
34
|
+
chatCount: e.chatCount,
|
|
35
|
+
exchangeCount: e.exchangeCount,
|
|
36
|
+
})),
|
|
37
|
+
index: { rebuilt: rebuilt.length, fromCache },
|
|
38
|
+
})
|
|
27
39
|
})
|
|
28
40
|
|
|
29
41
|
// POST /api/archive/now — snapshot active sessions into today's archive (non-destructive)
|
|
@@ -44,6 +56,46 @@ archiveRouter.post('/archive/now', async (_req, res) => {
|
|
|
44
56
|
res.json({ archived, date: todayDate })
|
|
45
57
|
})
|
|
46
58
|
|
|
59
|
+
// GET /api/archive/search — literal text search across archived days.
|
|
60
|
+
//
|
|
61
|
+
// REGISTRATION ORDER IS LOAD-BEARING. It must sit ABOVE /archive/:date: Express
|
|
62
|
+
// matches in order, so declared after it this path arrives as :date === 'search'
|
|
63
|
+
// and the param validator rejects it with 400 "Invalid date". That is exactly why
|
|
64
|
+
// /api/archive/dates has always looked like an empty archive.
|
|
65
|
+
//
|
|
66
|
+
// The scan never parses a day file — see archive-search.ts for why (one real day
|
|
67
|
+
// costs 1.2 GB of heap to materialise). Hits are attributed to a DATE plus text
|
|
68
|
+
// snippets; callers open /archive/:date/chats for structure.
|
|
69
|
+
archiveRouter.get('/archive/search', async (req, res) => {
|
|
70
|
+
const q = typeof req.query.q === 'string' ? req.query.q : ''
|
|
71
|
+
const from = typeof req.query.from === 'string' ? req.query.from : undefined
|
|
72
|
+
const to = typeof req.query.to === 'string' ? req.query.to : undefined
|
|
73
|
+
const rawLimit = Number.parseInt(String(req.query.limit ?? ''), 10)
|
|
74
|
+
const limit = Number.isFinite(rawLimit) ? rawLimit : DEFAULT_LIMIT
|
|
75
|
+
|
|
76
|
+
for (const [name, value] of [['from', from], ['to', to]] as const) {
|
|
77
|
+
if (value !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
78
|
+
res.status(400).json({ error: `Invalid ${name}`, expected: 'YYYY-MM-DD' })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const started = Date.now()
|
|
84
|
+
try {
|
|
85
|
+
const result = await searchArchive({
|
|
86
|
+
dir: archiveDir(),
|
|
87
|
+
dates: listArchiveDateStrings(),
|
|
88
|
+
query: q,
|
|
89
|
+
from,
|
|
90
|
+
to,
|
|
91
|
+
limit: Math.min(limit, MAX_LIMIT),
|
|
92
|
+
})
|
|
93
|
+
res.json({ query: q.trim(), ...result, elapsedMs: Date.now() - started })
|
|
94
|
+
} catch (error) {
|
|
95
|
+
res.status(400).json({ error: (error as Error).message })
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
47
99
|
// GET /api/archive/:date — full daily archive
|
|
48
100
|
archiveRouter.get('/archive/:date', (req, res) => {
|
|
49
101
|
const archive = loadArchive(req.params.date)
|
package/server/routes/meeting.ts
CHANGED
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
findQuarantineDir,
|
|
84
84
|
listUnsavedCaptures,
|
|
85
85
|
markRecovered,
|
|
86
|
+
markRecoveredNoSpeech,
|
|
86
87
|
registerActiveRecovery,
|
|
87
88
|
} from '../lib/unsaved-audio-quarantine.js'
|
|
88
89
|
import {
|
|
@@ -1915,7 +1916,24 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1915
1916
|
clearSessionHallucinationState(sessionId)
|
|
1916
1917
|
const transcript = cleanFinalTranscript(results.map(item => item.text).join(' '))
|
|
1917
1918
|
if (!transcript.trim()) {
|
|
1918
|
-
|
|
1919
|
+
// SILENCE IS AN OUTCOME, NOT A FAILURE.
|
|
1920
|
+
//
|
|
1921
|
+
// This used to throw, which left the capture in the unsaved list and
|
|
1922
|
+
// retried it forever -- 1,131 times for one 33-second capture on
|
|
1923
|
+
// 2026-08-25, alternating with the auto-recover path claiming success,
|
|
1924
|
+
// while the panel showed only "1 recoverable" and the error went to
|
|
1925
|
+
// stderr. The user sees a Recover button that does nothing, because it
|
|
1926
|
+
// literally cannot succeed on audio with no speech in it.
|
|
1927
|
+
//
|
|
1928
|
+
// Receipt it and let it clear. The audio stays on disk for its full
|
|
1929
|
+
// retention window, so a capture wrongly judged silent is not lost.
|
|
1930
|
+
markRecoveredNoSpeech(quarantineDir, entries.length)
|
|
1931
|
+
console.log(
|
|
1932
|
+
`[meeting/orphans] ${sessionId} held no speech `
|
|
1933
|
+
+ `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s) `
|
|
1934
|
+
+ '— receipted as no_speech; audio retained until retention expires',
|
|
1935
|
+
)
|
|
1936
|
+
return
|
|
1919
1937
|
}
|
|
1920
1938
|
const recoveredChunks = results.map(item => ({
|
|
1921
1939
|
text: item.text,
|