@gotcos/glasses-server 6.38.0 → 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,23 @@
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
+
1
21
  ## 6.38.0
2
22
 
3
23
  Six months of archived conversation you can finally search.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.38.0",
3
+ "version": "6.38.1",
4
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": {
@@ -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)
@@ -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()