@gotcos/glasses-server 6.37.3 → 6.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,55 @@
1
+ ## 6.38.1
2
+
3
+ The conversation archive was quietly storing the same conversations over and over.
4
+
5
+ FIXED: `appendToArchive` blind-appended, and the daily mirror re-archives every
6
+ still-resident prior-day session at boot and every 24h without evicting it -- so
7
+ each restart added another copy. Measured on a real install: 1.268 GB of archive
8
+ of which 99.3% was duplicate; one 69 MB day file held a single conversation
9
+ 2,388 times. This also inflated the archive index's chat counts and archive
10
+ search's match counts (both from 6.38.0) by up to ~2,200x on affected days.
11
+
12
+ The merge now upserts on (sessionId, startedAt), replaces a chat that has grown,
13
+ returns without rewriting when nothing changed, and self-heals a file written by
14
+ the old code the next time it is touched.
15
+
16
+ NEW: `server/scripts/repair-archive-duplicates.ts` -- a dry-run-by-default repair
17
+ for day files the mirror no longer touches. Backs up every file before writing,
18
+ refuses while the server is running, verifies by unique-chat count (never file
19
+ size), and is idempotent.
20
+
21
+ ## 6.38.0
22
+
23
+ Six months of archived conversation you can finally search.
24
+
25
+ NEW: GET /api/archive/search?q=&from=&to=&limit=. The archive already held every
26
+ day's conversations -- 175 day files spanning six months on a real install -- with
27
+ no way to find anything in them.
28
+
29
+ The scan never calls JSON.parse. Day sizes are wildly skewed: the median day is
30
+ 36 KB, but the largest measured is 343 MB of agent transcript, and materialising
31
+ that one day costs 1.2 GB heap / 2.3 GB RSS. On a process that also runs the
32
+ wearer's live session that is not affordable, so days are scanned as raw bytes
33
+ through a stream. A hit is therefore attributed to a DATE plus surrounding text,
34
+ not to a chat; open /archive/:date/chats for structure. A full 90-day scan
35
+ measures at 2.4 s, so there is no index to build and nothing that can drift.
36
+
37
+ FIXED: GET /api/archive no longer parses 1.2 GB to list days. It built {date,
38
+ summary, chatCount, exchangeCount} by parsing EVERY day file and discarding the
39
+ bodies -- one request away from a multi-gigabyte spike. It now reads a sidecar
40
+ index keyed by each day's (size, mtimeMs). Measured on the real corpus: cold build
41
+ 2,208 ms at 248 MB RSS for all 175 days, warm read 1 ms. Both halves of the key
42
+ matter -- size alone misses an in-place edit that preserves length.
43
+
44
+ listArchiveDates() is deleted rather than deprecated. It had one caller, and an
45
+ uncalled landmine is still a landmine.
46
+
47
+ Route order is load-bearing: /archive/search is registered ABOVE /archive/:date,
48
+ because Express matches in order and the date validator would otherwise reject it
49
+ as :date === "search". That is the same trap that has always made
50
+ /api/archive/dates look like an empty archive. Older servers still exhibit it, so
51
+ clients should read a 400 "Invalid date" from the search path as "route absent".
52
+
1
53
  ## 6.37.3
2
54
 
3
55
  Speaker ID works out of the box. A silent capture stops retrying forever. Live
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.37.3",
4
- "description": "COS Glasses self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
3
+ "version": "6.38.1",
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
+ }
@@ -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 listArchiveDates into its catch → the whole
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 = []
@@ -343,12 +343,59 @@ export async function appendToArchive(
343
343
  }
344
344
 
345
345
  if (existing) {
346
- // Merge: re-number chat IDs
347
- const nextId = existing.chats.length
348
- for (let i = 0; i < newChats.length; i++) {
349
- newChats[i].id = nextId + i
346
+ // UPSERT, never blind-append.
347
+ //
348
+ // `runDailyArchiveMirror` walks every session still resident in memory, skips
349
+ // only TODAY's, and archives the rest -- at boot and every 24h -- WITHOUT
350
+ // evicting them from the map. This merge used to be
351
+ // `existing.chats.push(...newChats)`, so a session that stayed resident gained
352
+ // one more copy of itself in its day file on every single restart, forever.
353
+ //
354
+ // Measured on the live corpus before this fix: 1.28 GB across 176 day files, of
355
+ // which ~1.26 GB (98%) was duplicates, 31 files affected. 2026-07-30.json was
356
+ // 343 MB holding 4,421 chats belonging to exactly TWO sessions -- ~2,210 copies
357
+ // of roughly 0.16 MB of real content. 2026-07-28.json was 69 MB of ONE
358
+ // conversation, 2,388 times over. That is why the archive index and archive
359
+ // search (both shipped in 6.38.0) reported inflated counts, and why a day file
360
+ // ever reached a size that costs 1.2 GB of heap to parse.
361
+ //
362
+ // IDENTITY IS (sessionId, startedAt). `startedAt` is the chat's first exchange
363
+ // timestamp, so it survives re-archiving. `id` does NOT -- it is renumbered on
364
+ // every merge, which is precisely why the old code could never recognise a chat
365
+ // it had already written. Verified against the real corpus: this key collapses
366
+ // 2026-07-30 from 4,421 chats to 2, and leaves an unaffected day (2026-08-17,
367
+ // 12 chats) at exactly 12 -- so it does not over-merge distinct conversations.
368
+ const keyOf = (c: ArchivedChat): string => `${c.sessionId}:${c.startedAt}`
369
+ const byKey = new Map<string, ArchivedChat>()
370
+ for (const chat of existing.chats) {
371
+ const key = keyOf(chat)
372
+ const prior = byKey.get(key)
373
+ // Self-heal: a file written before this fix already contains duplicates.
374
+ // Keep the most complete copy rather than the first one encountered.
375
+ if (!prior || chat.exchangeCount > prior.exchangeCount) byKey.set(key, chat)
350
376
  }
351
- existing.chats.push(...newChats)
377
+ const hadDuplicates = byKey.size !== existing.chats.length
378
+
379
+ let changed = false
380
+ for (const incoming of newChats) {
381
+ const key = keyOf(incoming)
382
+ const prior = byKey.get(key)
383
+ // REPLACE rather than skip: a session archived yesterday with 5 exchanges that
384
+ // now holds 8 must update, not be discarded as "already seen".
385
+ if (!prior || incoming.exchangeCount > prior.exchangeCount) {
386
+ byKey.set(key, incoming)
387
+ changed = true
388
+ }
389
+ }
390
+
391
+ // Nothing new and nothing to repair: return WITHOUT rewriting. Re-serialising a
392
+ // large day file to produce identical bytes is pure cost on the process that
393
+ // also records the wearer's live session, and it is the common case at boot.
394
+ if (!changed && !hadDuplicates) return
395
+
396
+ const merged = [...byKey.values()].sort((a, b) => a.startedAt - b.startedAt)
397
+ merged.forEach((chat, i) => { chat.id = i })
398
+ existing.chats = merged
352
399
  existing.summary = await generateDaySummary(existing.chats, opts.skipLLM)
353
400
  existing.archivedAt = new Date().toISOString()
354
401
  saveArchive(existing)
@@ -367,30 +414,51 @@ export async function appendToArchive(
367
414
  // ── Query functions ─────────────────────────────────────────
368
415
 
369
416
  /** List all archive dates with summaries */
370
- export function listArchiveDates(): ArchiveDateSummary[] {
417
+ /** Sidecar index location. Deliberately a SIBLING of the archive directory, not
418
+ * a file inside it: anything living in that directory has to be excluded by
419
+ * every readdir filter forever, and one missed filter turns the cache into a
420
+ * phantom "day". */
421
+ export function archiveIndexPath(): string {
422
+ return dataPath('archive-index.json')
423
+ }
424
+
425
+ /** The archive directory, for readers that must NOT materialise a day file. */
426
+ export function archiveDir(): string {
427
+ return ensureArchiveDir()
428
+ }
429
+
430
+ /**
431
+ * Date strings only, straight from readdir — no file is opened.
432
+ *
433
+ * The richer per-day summaries (chat and exchange counts) come from the sidecar
434
+ * index in archive-index.ts. The listing this replaced reached them by
435
+ * JSON.parsing EVERY day file. Measured on the real corpus that is 1.2 GB across
436
+ * 175 files, and the single largest day (2026-07-30, 343 MB) costs 1.2 GB heap /
437
+ * 2.3 GB RSS to parse on its own. Anything that only needs to know WHICH days
438
+ * exist must use this instead, on a server that is also running the wearer's live
439
+ * session.
440
+ */
441
+ export function listArchiveDateStrings(): string[] {
371
442
  try {
372
443
  ensureArchiveDir()
373
- const files = readdirSync(ARCHIVE_DIR)
444
+ return readdirSync(ARCHIVE_DIR)
374
445
  .filter(f => f.endsWith('.json'))
446
+ .map(f => f.slice(0, -'.json'.length))
447
+ .filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
375
448
  .sort()
376
- .reverse() // newest first
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[]
449
+ .reverse()
389
450
  } catch {
390
451
  return []
391
452
  }
392
453
  }
393
454
 
455
+ // listArchiveDates() REMOVED (2026-08-26). It produced its summaries by
456
+ // JSON.parsing every day file -- 175 files / 1.2 GB on the real corpus, and 1.2 GB
457
+ // heap / 2.3 GB RSS for the single largest day alone -- on the process that also
458
+ // runs the wearer's live session. The /api/archive route now reads the sidecar
459
+ // index (archive-index.ts) instead. Deleted rather than deprecated: an uncalled
460
+ // landmine is still a landmine, and the next caller would not know.
461
+
394
462
  /** Get chat summaries for a specific day */
395
463
  export function getArchiveChats(date: string): ArchiveChatSummary[] {
396
464
  const archive = loadArchive(date)
@@ -1,7 +1,9 @@
1
1
  // Archive endpoints — daily conversation archive for glasses history browser
2
2
  import { Router } from 'express'
3
- import { listArchiveDates, loadArchive, getArchiveChats, getArchiveDayMessages, appendToArchive } from '../lib/archive.js'
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
- const archives = listArchiveDates()
26
- res.json({ archives })
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)
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env tsx
2
+ // Repair day files written by the pre-upsert archive merge.
3
+ //
4
+ // npx tsx server/scripts/repair-archive-duplicates.ts # dry run, writes nothing
5
+ // npx tsx server/scripts/repair-archive-duplicates.ts --apply # rewrites, after backing up
6
+ //
7
+ // WHAT WENT WRONG. `runDailyArchiveMirror` re-archives every session still
8
+ // resident in memory, skipping only today's, at boot and every 24h -- without
9
+ // evicting it. `appendToArchive` merged with a blind `existing.chats.push(...)`.
10
+ // So a session that stayed resident gained one more copy of itself in its day
11
+ // file on every restart. Measured before the fix: 1.28 GB across 176 day files,
12
+ // ~1.26 GB of it duplicates. One 69 MB file held ONE conversation 2,388 times.
13
+ //
14
+ // The upsert in archive.ts fixes new writes AND self-heals a file the next time
15
+ // it is touched -- so most affected days repair themselves once the mirror
16
+ // revisits them. This script exists for the remainder: days whose sessions have
17
+ // since been evicted, which nothing will ever touch again.
18
+ //
19
+ // THIS SCRIPT IMPORTS NOTHING FROM THE SERVER. `archive.ts` runs
20
+ // checkYesterdayArchive() at module scope, so importing it would start archive
21
+ // work while we are rewriting the archive. The atomic write below is inlined for
22
+ // the same reason. Nothing here has an effect until --apply.
23
+
24
+ import { execFileSync } from 'node:child_process'
25
+ import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { join, resolve } from 'node:path'
28
+ import { pathToFileURL } from 'node:url'
29
+
30
+ const APPLY = process.argv.includes('--apply')
31
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
32
+
33
+ interface Chat {
34
+ id: number
35
+ sessionId: string
36
+ startedAt: number
37
+ exchangeCount: number
38
+ [k: string]: unknown
39
+ }
40
+ interface Day { date: string; summary: string; chats: Chat[]; archivedAt: string; [k: string]: unknown }
41
+
42
+ function archiveDirPath(): string {
43
+ const base = process.env.COS_DATA_DIR ?? join(homedir(), '.cos-glasses', 'data')
44
+ return resolve(base, 'archive')
45
+ }
46
+
47
+ /** Identity of a chat. `startedAt` is its first exchange's timestamp, so it
48
+ * survives re-archiving. `id` does NOT -- it is renumbered on every merge,
49
+ * which is exactly why the old code could never see a duplicate. */
50
+ const keyOf = (c: Chat): string => `${c.sessionId}:${c.startedAt}`
51
+
52
+ /** Collapse duplicates, keeping the most complete copy of each chat. Pure. */
53
+ export function dedupeChats(chats: Chat[]): { kept: Chat[]; removed: number } {
54
+ const byKey = new Map<string, Chat>()
55
+ for (const chat of chats) {
56
+ const prior = byKey.get(keyOf(chat))
57
+ if (!prior || (chat.exchangeCount ?? 0) > (prior.exchangeCount ?? 0)) byKey.set(keyOf(chat), chat)
58
+ }
59
+ const kept = [...byKey.values()].sort((a, b) => a.startedAt - b.startedAt)
60
+ kept.forEach((c, i) => { c.id = i })
61
+ return { kept, removed: chats.length - kept.length }
62
+ }
63
+
64
+ function atomicWrite(path: string, data: string): void {
65
+ // Inlined rather than imported: see the header note about module-scope effects.
66
+ const tmp = `${path}.repair-tmp`
67
+ writeFileSync(tmp, data, { encoding: 'utf8', mode: 0o600 })
68
+ renameSync(tmp, path)
69
+ }
70
+
71
+ function main(): void {
72
+ const dir = archiveDirPath()
73
+ if (!existsSync(dir)) {
74
+ console.error(`No archive directory at ${dir}`)
75
+ process.exit(2)
76
+ }
77
+
78
+ // A concurrent appendToArchive would race this rewrite. The in-process archive
79
+ // lock cannot be taken from outside the server, so the only safe answer is to
80
+ // refuse while it is up rather than to hope the window is small.
81
+ // Only the DEFAULT data dir is at risk: that is the one the running server writes
82
+ // to. Pointed at a scratch copy, there is nothing to race, and refusing there
83
+ // would block the very rehearsal this script deserves before it touches real data.
84
+ const isLiveDataDir = process.env.COS_DATA_DIR === undefined
85
+ if (APPLY && isLiveDataDir && serverIsUp()) {
86
+ console.error('The COS server is listening on 127.0.0.1:3141.')
87
+ console.error('Stop it through COS Control before repairing, then re-run.')
88
+ console.error('Refusing to rewrite archive files while the server may write to them.')
89
+ process.exit(3)
90
+ }
91
+
92
+ const files = readdirSync(dir)
93
+ .filter(f => f.endsWith('.json') && DATE_RE.test(f.slice(0, -5)))
94
+ .sort()
95
+
96
+ let affected = 0
97
+ let chatsBefore = 0
98
+ let chatsAfter = 0
99
+ let bytesBefore = 0
100
+ let bytesAfter = 0
101
+
102
+ for (const file of files) {
103
+ const path = join(dir, file)
104
+ const size = statSync(path).size
105
+
106
+ let day: Day
107
+ try {
108
+ day = JSON.parse(readFileSync(path, 'utf8')) as Day
109
+ } catch (err) {
110
+ console.error(` SKIP ${file} — unreadable: ${(err as Error).message.slice(0, 80)}`)
111
+ continue
112
+ }
113
+ if (!Array.isArray(day.chats) || day.chats.length === 0) continue
114
+
115
+ const { kept, removed } = dedupeChats(day.chats)
116
+ if (removed === 0) continue
117
+
118
+ affected++
119
+ chatsBefore += day.chats.length
120
+ chatsAfter += kept.length
121
+ bytesBefore += size
122
+
123
+ const before = day.chats.length
124
+ day.chats = kept
125
+ const serialised = `${JSON.stringify(day, null, 2)}\n`
126
+ bytesAfter += Buffer.byteLength(serialised, 'utf8')
127
+
128
+ console.log(
129
+ ` ${APPLY ? 'REPAIR' : 'would repair'} ${file} ` +
130
+ `${(size / 1e6).toFixed(1)} MB → ${(Buffer.byteLength(serialised, 'utf8') / 1e6).toFixed(1)} MB ` +
131
+ `chats ${before} → ${kept.length} (-${removed})`,
132
+ )
133
+
134
+ if (APPLY) {
135
+ // Back up BEFORE writing. This is user conversation history; a bad rewrite
136
+ // with no copy is unrecoverable.
137
+ const backup = `${path}.bak-${Date.now()}`
138
+ copyFileSync(path, backup)
139
+ atomicWrite(path, serialised)
140
+
141
+ // Verify by UNIQUE CHAT COUNT, never by file size -- size is the metric the
142
+ // bug distorted, so shrinkage proves nothing about correctness.
143
+ const reread = JSON.parse(readFileSync(path, 'utf8')) as Day
144
+ const uniq = new Set(reread.chats.map(keyOf)).size
145
+ if (reread.chats.length !== kept.length || uniq !== kept.length) {
146
+ console.error(` FAILED verification on ${file}; original preserved at ${backup}`)
147
+ process.exit(4)
148
+ }
149
+ }
150
+ }
151
+
152
+ const summary = {
153
+ mode: APPLY ? 'applied' : 'dry-run',
154
+ filesScanned: files.length,
155
+ filesAffected: affected,
156
+ chats: { before: chatsBefore, after: chatsAfter, removed: chatsBefore - chatsAfter },
157
+ bytes: { before: bytesBefore, after: bytesAfter, reclaimed: bytesBefore - bytesAfter },
158
+ }
159
+ console.log('')
160
+ console.log(JSON.stringify(summary, null, 2))
161
+ if (!APPLY && affected > 0) {
162
+ console.log('')
163
+ console.log('Nothing was written. Re-run with --apply to repair (each file is backed up first).')
164
+ }
165
+ }
166
+
167
+ function serverIsUp(): boolean {
168
+ try {
169
+ const out = execFileSync('/usr/sbin/lsof', ['-ti', ':3141'], { encoding: 'utf8', timeout: 5000 })
170
+ return out.trim().length > 0
171
+ } catch {
172
+ return false // lsof missing or nothing listening — do not block on an inconclusive probe
173
+ }
174
+ }
175
+
176
+ // Run ONLY when invoked directly. Importing this file (a test, or any tooling)
177
+ // must not execute a repair or call process.exit -- the same module-scope hazard
178
+ // this script refuses to inherit from archive.ts.
179
+ const invokedDirectly = process.argv[1] !== undefined
180
+ && import.meta.url === pathToFileURL(process.argv[1]).href
181
+ if (invokedDirectly) main()