@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 +20 -0
- package/package.json +1 -1
- package/server/lib/archive.ts +52 -5
- package/server/scripts/repair-archive-duplicates.ts +181 -0
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.
|
|
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": {
|
package/server/lib/archive.ts
CHANGED
|
@@ -343,12 +343,59 @@ export async function appendToArchive(
|
|
|
343
343
|
}
|
|
344
344
|
|
|
345
345
|
if (existing) {
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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.
|
|
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()
|