@gotcos/glasses-server 6.99.12 → 6.99.16

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.
@@ -0,0 +1,582 @@
1
+ /**
2
+ * 6.99.16: the COS thread. One persistent, size-capped conversation behind the COS chat's Ask (routes/cos-thread.ts).
3
+ *
4
+ * Why: every Ask from the COS chat used to start a brand-new session, and a fresh session already starts at about
5
+ * 100K tokens of context (CLAUDE.md, gotchas, memory, tool definitions; 101,720 and 103,116 on the first call in two
6
+ * session logs read on 2026-11-05). Continuing a session while its prompt cache is warm is cheaper than starting
7
+ * fresh; what makes a forever thread expensive is size. So: one thread continues the same session until that
8
+ * session's context passes a cap (COS_THREAD_CAP_TOKENS, default 150,000, about 50K of conversation on top of the
9
+ * baseline), then the next Ask starts a fresh session carrying a short mechanical digest (no model call).
10
+ *
11
+ * Storage, so a thread can always be recovered and searched:
12
+ * DATA_DIR/cos-thread/ledger.jsonl append-only, every row ever written (authoritative)
13
+ * DATA_DIR/cos-thread/state.json a snapshot of the current thread, written atomically after each ledger append;
14
+ * rebuilt from the ledger when it is missing, corrupt or behind the ledger.
15
+ * Directory 0700, files 0600. Ledger rows:
16
+ * { kind: 'thread', id, at, from? } a thread starts (from: the thread New thread closed)
17
+ * { kind: 'turn', threadId, segment, sessionId, clientId, at, query, answerHead, messageNo, provider, native? }
18
+ * { kind: 'rollover', threadId, at, reason, fromSessionId, digest }
19
+ * { kind: 'close', threadId, at, reason }
20
+ *
21
+ * `sessionId` is the COS conversation id (the one POST /api/query-jobs takes and reports). Its Claude Code log is
22
+ * found through the CLI session the bridge holds for it (`native`), read from the tail only.
23
+ */
24
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, writeSync, fsyncSync, fchmodSync } from 'node:fs'
25
+ import { randomBytes } from 'node:crypto'
26
+ import { homedir } from 'node:os'
27
+ import { join } from 'node:path'
28
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
29
+ import { securePrivateDirectory } from './secure-user-config.js'
30
+
31
+ export const COS_THREAD_PROTOCOL = 1
32
+ export const COS_THREAD_DEFAULT_CAP = 150_000
33
+ export const COS_THREAD_CAP_MIN = 20_000
34
+ export const COS_THREAD_CAP_MAX = 1_000_000
35
+ export const DIGEST_MAX_CHARS = 6000
36
+ export const DIGEST_TURNS = 8
37
+ export const DIGEST_DROPS = 12
38
+ export const DIGEST_QUERY_CHARS = 200
39
+ export const DIGEST_ANSWER_CHARS = 300
40
+ export const DIGEST_DROP_CHARS = 160
41
+ export const SEARCH_MEMORY_TURNS = 5000
42
+ export const ANSWER_HEAD_CHARS = 2000
43
+ /** Kept in memory per turn for search; the ledger keeps the whole query. */
44
+ export const MEMORY_QUERY_CHARS = 4000
45
+ export const LOG_TAIL_BYTES = 512 * 1024
46
+ export const DIGEST_HEADER = 'Earlier in this COS thread (a summary carried over; ask COS to search History or Messages for more):'
47
+ const DAY_MS = 24 * 60 * 60_000
48
+
49
+ export type NextReason = 'continue' | 'empty' | 'size' | 'missing_session' | 'manual'
50
+ export interface CosThreadView {
51
+ id: string
52
+ startedAt: string
53
+ lastAskAt: string | null
54
+ asks: number
55
+ segments: number
56
+ sessionId: string | null
57
+ provider: string | null
58
+ contextTokens: number | null
59
+ }
60
+ export interface CosThreadNext {
61
+ mode: 'continue' | 'fresh'
62
+ sessionId: string | null
63
+ reason: NextReason
64
+ digest: string | null
65
+ }
66
+ export interface CosThreadAnswer {
67
+ protocol: 1
68
+ capTokens: number
69
+ thread: CosThreadView
70
+ next: CosThreadNext
71
+ }
72
+ export interface CosThreadTurnInput {
73
+ clientId: string
74
+ sessionId: string
75
+ query: string
76
+ answer?: string
77
+ messageNo?: number
78
+ provider?: string
79
+ }
80
+ export interface CosThreadSearchHit {
81
+ threadId: string
82
+ sessionId: string
83
+ at: string
84
+ query: string
85
+ answerHead: string
86
+ messageNo: number | null
87
+ }
88
+ export interface CosThreadSearchAnswer { hits: CosThreadSearchHit[]; scanned: number }
89
+ export interface CosThreadSummary {
90
+ id: string
91
+ startedAt: string
92
+ endedAt: string | null
93
+ asks: number
94
+ segments: number
95
+ title: string
96
+ }
97
+ /** A drop as the digest carries it: its words and where it was filed. */
98
+ export interface DigestDrop { text: string; createdAt: string; filed: string }
99
+ /** found: the session and its log exist. tokens: the last assistant message's context, null when unreadable. */
100
+ export interface ContextReading { found: boolean; tokens: number | null }
101
+
102
+ export class CosThreadError extends Error {
103
+ constructor(readonly code: string, readonly status: number) { super(code) }
104
+ }
105
+
106
+ // ── rows ──────────────────────────────────────────────
107
+ interface ThreadRow { kind: 'thread'; id: string; at: string; from?: string }
108
+ interface TurnRow {
109
+ kind: 'turn'; threadId: string; segment: number; sessionId: string; clientId: string; at: string
110
+ query: string; answerHead: string; messageNo: number | null; provider: string | null; native?: string
111
+ }
112
+ interface RolloverRow { kind: 'rollover'; threadId: string; at: string; reason: NextReason; fromSessionId: string | null; digest: string | null }
113
+ interface CloseRow { kind: 'close'; threadId: string; at: string; reason: string }
114
+ type LedgerRow = ThreadRow | TurnRow | RolloverRow | CloseRow
115
+
116
+ interface CurrentThread {
117
+ id: string
118
+ startedAt: string
119
+ lastAskAt: string | null
120
+ asks: number
121
+ segments: number
122
+ sessionId: string | null
123
+ nativeSessionId: string | null
124
+ provider: string | null
125
+ /** The thread New thread closed to start this one; its turns are carried until this one has its own. */
126
+ from: string | null
127
+ }
128
+ interface StateFile { schema: 1; ledgerRows: number; current: CurrentThread }
129
+ interface MemoryTurn { threadId: string; sessionId: string; clientId: string; at: string; query: string; answerHead: string; messageNo: number | null }
130
+
131
+ // ── validation ────────────────────────────────────────
132
+ const CLIENT_ID_RE = /^[A-Za-z0-9:_-]{8,80}$/
133
+ const SESSION_ID_RE = /^[A-Za-z0-9-]{8,80}$/
134
+ const PROVIDER_RE = /^[a-z0-9-]{1,32}$/
135
+ const TURN_KEYS = new Set(['clientId', 'sessionId', 'query', 'answer', 'messageNo', 'provider'])
136
+
137
+ /** The turn body, or null when any field is out of contract (the route answers 400 invalid_turn). */
138
+ export function parseTurnInput(raw: unknown): CosThreadTurnInput | null {
139
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
140
+ const b = raw as Record<string, unknown>
141
+ if (Object.keys(b).some(k => !TURN_KEYS.has(k))) return null
142
+ if (typeof b.clientId !== 'string' || !CLIENT_ID_RE.test(b.clientId)) return null
143
+ if (typeof b.sessionId !== 'string' || !SESSION_ID_RE.test(b.sessionId)) return null
144
+ if (typeof b.query !== 'string' || b.query.length < 1 || b.query.length > 20_000 || !b.query.trim()) return null
145
+ if (b.answer !== undefined && b.answer !== null && (typeof b.answer !== 'string' || b.answer.length > 40_000)) return null
146
+ if (b.messageNo !== undefined && b.messageNo !== null && (typeof b.messageNo !== 'number' || !Number.isSafeInteger(b.messageNo) || b.messageNo < 1)) return null
147
+ if (b.provider !== undefined && b.provider !== null && (typeof b.provider !== 'string' || !PROVIDER_RE.test(b.provider))) return null
148
+ return {
149
+ clientId: b.clientId,
150
+ sessionId: b.sessionId,
151
+ query: b.query,
152
+ ...(typeof b.answer === 'string' ? { answer: b.answer } : {}),
153
+ ...(typeof b.messageNo === 'number' ? { messageNo: b.messageNo } : {}),
154
+ ...(typeof b.provider === 'string' ? { provider: b.provider } : {}),
155
+ }
156
+ }
157
+
158
+ /** COS_THREAD_CAP_TOKENS: a whole number from 20,000 to 1,000,000; anything else is the default 150,000. */
159
+ export function cosThreadCapTokens(env: NodeJS.ProcessEnv = process.env): number {
160
+ const raw = env.COS_THREAD_CAP_TOKENS?.trim()
161
+ if (!raw || !/^\d+$/.test(raw)) return COS_THREAD_DEFAULT_CAP
162
+ const n = Number(raw)
163
+ return Number.isSafeInteger(n) && n >= COS_THREAD_CAP_MIN && n <= COS_THREAD_CAP_MAX ? n : COS_THREAD_DEFAULT_CAP
164
+ }
165
+
166
+ // ── context tokens from a Claude Code session log ─────
167
+ export function defaultClaudeProjectsDir(env: NodeJS.ProcessEnv = process.env): string {
168
+ const override = env.COS_CLAUDE_PROJECTS_DIR?.trim()
169
+ if (override) return override
170
+ const config = env.CLAUDE_CONFIG_DIR?.trim()
171
+ return join(config || join(homedir(), '.claude'), 'projects')
172
+ }
173
+
174
+ /** `<projectsDir>/<any project dir>/<id>.jsonl`, or null. A symlink or a non-file never counts. */
175
+ export function findSessionLog(projectsDir: string, id: string): string | null {
176
+ if (!SESSION_LOG_ID_RE.test(id)) return null
177
+ let dirs: string[]
178
+ try { dirs = readdirSync(projectsDir) } catch { return null }
179
+ for (const dir of dirs) {
180
+ const file = join(projectsDir, dir, `${id}.jsonl`)
181
+ try {
182
+ const stat = lstatSync(file)
183
+ if (stat.isFile()) return file
184
+ } catch { /* not in this project */ }
185
+ }
186
+ return null
187
+ }
188
+ const SESSION_LOG_ID_RE = /^[A-Za-z0-9-]{8,80}$/
189
+
190
+ /** The last tail of a file, at most `max` bytes. */
191
+ function readTail(file: string, max: number): { text: string; truncated: boolean } {
192
+ // O_NONBLOCK: a FIFO planted at the log path must not stop the event loop (hazard-invariants.test.ts).
193
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
194
+ if (!fstatSync(fd).isFile()) { closeSync(fd); throw new CosThreadError('cos_thread_unsafe', 503) }
195
+ try {
196
+ const size = fstatSync(fd).size
197
+ const length = Math.min(size, max)
198
+ const buffer = Buffer.alloc(length)
199
+ let read = 0
200
+ while (read < length) {
201
+ const n = readSync(fd, buffer, read, length - read, size - length + read)
202
+ if (n <= 0) break
203
+ read += n
204
+ }
205
+ return { text: buffer.subarray(0, read).toString('utf8'), truncated: size > length }
206
+ } finally { closeSync(fd) }
207
+ }
208
+
209
+ /**
210
+ * The context of a session's last assistant message: input_tokens + cache_creation_input_tokens +
211
+ * cache_read_input_tokens. Reads only the last 512 KB. Sidechain (subagent) messages are skipped.
212
+ */
213
+ export function contextTokensFromLog(file: string, tailBytes = LOG_TAIL_BYTES): number | null {
214
+ let tail: { text: string; truncated: boolean }
215
+ try { tail = readTail(file, tailBytes) } catch { return null }
216
+ const lines = tail.text.split('\n')
217
+ if (tail.truncated) lines.shift() // the first line was cut in the middle
218
+ for (let i = lines.length - 1; i >= 0; i--) {
219
+ const line = lines[i]!.trim()
220
+ if (!line || !line.includes('"usage"')) continue
221
+ let row: any
222
+ try { row = JSON.parse(line) } catch { continue }
223
+ if (!row || row.type !== 'assistant' || row.isSidechain === true) continue
224
+ const usage = row.message?.usage
225
+ if (!usage || typeof usage !== 'object') continue
226
+ const parts = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens]
227
+ if (!parts.some(v => typeof v === 'number')) continue
228
+ return parts.reduce((sum: number, v) => sum + (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0), 0)
229
+ }
230
+ return null
231
+ }
232
+
233
+ export interface ClaudeContextReaderOptions {
234
+ projectsDir?: () => string
235
+ /** The COS conversation still exists. Absent: assumed to. */
236
+ sessionExists?: (sessionId: string) => boolean
237
+ /** The CLI session the bridge holds for a COS conversation right now. */
238
+ nativeFor?: (sessionId: string) => string | undefined
239
+ }
240
+ /** Reads a COS conversation's context from its Claude Code log (the live CLI session first, then the one recorded). */
241
+ export function claudeContextReader(options: ClaudeContextReaderOptions = {}): (sessionId: string, native: string | null) => ContextReading {
242
+ const located = new Map<string, string>()
243
+ return (sessionId, native) => {
244
+ if (options.sessionExists && !options.sessionExists(sessionId)) return { found: false, tokens: null }
245
+ const dir = (options.projectsDir ?? defaultClaudeProjectsDir)()
246
+ const ids = [...new Set([options.nativeFor?.(sessionId), native, sessionId].filter((v): v is string => !!v))]
247
+ for (const id of ids) {
248
+ let file = located.get(id) ?? null
249
+ if (file && !existsSync(file)) { located.delete(id); file = null }
250
+ file ??= findSessionLog(dir, id)
251
+ if (!file) continue
252
+ located.set(id, file)
253
+ return { found: true, tokens: contextTokensFromLog(file) }
254
+ }
255
+ return { found: false, tokens: null }
256
+ }
257
+ }
258
+
259
+ // ── the digest ────────────────────────────────────────
260
+ function oneLine(s: string, n: number): string {
261
+ const flat = String(s || '').replace(/\s+/g, ' ').trim()
262
+ return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat
263
+ }
264
+ export interface DigestTurn { query: string; answerHead: string; messageNo: number | null }
265
+
266
+ /** Mechanical, no model: the last turns (oldest first) and the recent drops, at most 6000 characters. Null when empty. */
267
+ export function buildDigest(turns: readonly DigestTurn[], drops: readonly DigestDrop[]): string | null {
268
+ let t = turns.slice(-DIGEST_TURNS)
269
+ let d = drops.slice(0, DIGEST_DROPS)
270
+ if (!t.length && !d.length) return null
271
+ const render = () => {
272
+ const lines = [DIGEST_HEADER]
273
+ if (t.length) {
274
+ lines.push('', 'Recent asks, oldest first:')
275
+ for (const turn of t) {
276
+ lines.push(`- ${turn.messageNo ? `#${turn.messageNo} ` : ''}You asked: ${oneLine(turn.query, DIGEST_QUERY_CHARS)}`)
277
+ const answer = oneLine(turn.answerHead, DIGEST_ANSWER_CHARS)
278
+ if (answer) lines.push(` COS answered: ${answer}`)
279
+ }
280
+ }
281
+ if (d.length) {
282
+ lines.push('', 'Recent drops, oldest first:')
283
+ for (const drop of d) lines.push(`- ${oneLine(drop.text, DIGEST_DROP_CHARS)} (${oneLine(drop.filed, 80)})`)
284
+ }
285
+ return lines.join('\n')
286
+ }
287
+ let text = render()
288
+ // Over the cap: the oldest drops go first (down to 4), then the oldest turns (down to 2), then a hard cut.
289
+ while (text.length > DIGEST_MAX_CHARS && (d.length > 4 || t.length > 2)) {
290
+ if (d.length > 4) d = d.slice(1)
291
+ else t = t.slice(1)
292
+ text = render()
293
+ }
294
+ return text.length > DIGEST_MAX_CHARS ? text.slice(0, DIGEST_MAX_CHARS - 1) + '…' : text
295
+ }
296
+
297
+ const DEST_LABEL: Record<string, string> = {
298
+ new_card: 'filed as a new Work card', existing_card: 'added to a Work card', thread: 'added to a thread', memory: 'saved to memory',
299
+ session: 'sent to a session', note: 'saved as a note', reminder: 'set as a reminder', ask: 'asked COS',
300
+ }
301
+ /** Where a drop was filed, in words: its first link's label when it has one. */
302
+ export function describeDropFiling(drop: { state: string; dest: string | null; links?: ReadonlyArray<{ label?: string }> }): string {
303
+ if (drop.state === 'routing') return 'still being filed'
304
+ if (drop.state === 'asking' || drop.state === 'held') return drop.state === 'held' ? 'held for a session' : 'waiting on a choice'
305
+ const where = drop.dest ? DEST_LABEL[drop.dest] ?? 'filed' : 'filed'
306
+ const label = drop.links?.find(l => typeof l.label === 'string' && l.label.trim())?.label
307
+ return label ? `${where}: ${oneLine(label, 60)}` : where
308
+ }
309
+
310
+ // ── the store ─────────────────────────────────────────
311
+ export interface CosThreadDeps {
312
+ /** DATA_DIR/cos-thread */
313
+ dir: string
314
+ now?: () => number
315
+ capTokens?: () => number
316
+ context?: (sessionId: string, native: string | null) => ContextReading
317
+ /** Drops created at or after `sinceIso`, newest first, undone ones left out, at most `limit`. */
318
+ drops?: (sinceIso: string, limit: number) => DigestDrop[]
319
+ /** The CLI session for a COS conversation, recorded with each turn so a restart can still find the log. */
320
+ nativeFor?: (sessionId: string) => string | undefined
321
+ /** A turn recorded without its answer: the answer from the live conversation, when it is still there. */
322
+ answerFor?: (sessionId: string, clientId: string, messageNo: number | null) => string | undefined
323
+ newId?: () => string
324
+ }
325
+
326
+ export class CosThread {
327
+ private readonly ledgerFile: string
328
+ private readonly stateFile: string
329
+ private readonly now: () => number
330
+ private current!: CurrentThread
331
+ private ledgerRows = 0
332
+ private readonly clientIds = new Set<string>()
333
+ private readonly turns: MemoryTurn[] = []
334
+ private readonly threads = new Map<string, CosThreadSummary>()
335
+ private readonly order: string[] = []
336
+
337
+ constructor(private readonly deps: CosThreadDeps) {
338
+ this.now = deps.now ?? Date.now
339
+ securePrivateDirectory(deps.dir)
340
+ this.ledgerFile = join(deps.dir, 'ledger.jsonl')
341
+ this.stateFile = join(deps.dir, 'state.json')
342
+ this.load()
343
+ }
344
+
345
+ private iso(): string { return new Date(this.now()).toISOString() }
346
+ private newId(): string { return this.deps.newId?.() ?? `th_${randomBytes(8).toString('hex')}` }
347
+ cap(): number { return this.deps.capTokens?.() ?? cosThreadCapTokens() }
348
+
349
+ // ── loading ─────────────────────────────────────────
350
+ private load(): void {
351
+ const rows = this.readLedger()
352
+ for (const row of rows) this.apply(row)
353
+ this.ledgerRows = rows.length
354
+ if (!this.order.length) {
355
+ this.append({ kind: 'thread', id: this.newId(), at: this.iso() })
356
+ return
357
+ }
358
+ // The snapshot is used only when it matches the ledger; otherwise the ledger rebuild stands and is written out.
359
+ const snapshot = this.readState()
360
+ if (snapshot && snapshot.ledgerRows === this.ledgerRows && snapshot.current.id === this.current.id) this.current = snapshot.current
361
+ else this.writeState()
362
+ }
363
+ private readLedger(): LedgerRow[] {
364
+ if (!existsSync(this.ledgerFile)) return []
365
+ const stat = lstatSync(this.ledgerFile)
366
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new CosThreadError('cos_thread_unsafe', 503)
367
+ const text = readFileSync(this.ledgerFile, 'utf8')
368
+ // A torn last row (a crash mid-append) must not swallow the next one: end the file with a newline first.
369
+ if (text.length && !text.endsWith('\n')) this.appendRaw('\n')
370
+ const rows: LedgerRow[] = []
371
+ for (const line of text.split('\n')) {
372
+ if (!line.trim()) continue
373
+ try {
374
+ const row = JSON.parse(line) as LedgerRow
375
+ if (isRow(row)) rows.push(row)
376
+ } catch { /* a torn row is skipped */ }
377
+ }
378
+ return rows
379
+ }
380
+ private readState(): StateFile | null {
381
+ try {
382
+ const stat = lstatSync(this.stateFile)
383
+ if (stat.isSymbolicLink() || !stat.isFile()) return null
384
+ const value = JSON.parse(readFileSync(this.stateFile, 'utf8')) as StateFile
385
+ if (value?.schema !== 1 || !Number.isInteger(value.ledgerRows) || !value.current || typeof value.current.id !== 'string') return null
386
+ return value
387
+ } catch { return null }
388
+ }
389
+ private writeState(): void {
390
+ const value: StateFile = { schema: 1, ledgerRows: this.ledgerRows, current: this.current }
391
+ durableAtomicWriteFileSync(this.stateFile, JSON.stringify(value) + '\n', { mode: 0o600 })
392
+ }
393
+
394
+ /** One row into memory: the same code builds the state live and from the ledger, so a rebuild cannot drift. */
395
+ private apply(row: LedgerRow): void {
396
+ if (row.kind === 'thread') {
397
+ this.threads.set(row.id, { id: row.id, startedAt: row.at, endedAt: null, asks: 0, segments: 0, title: '' })
398
+ this.order.push(row.id)
399
+ this.current = { id: row.id, startedAt: row.at, lastAskAt: null, asks: 0, segments: 0, sessionId: null, nativeSessionId: null, provider: null, from: row.from ?? null }
400
+ return
401
+ }
402
+ if (row.kind === 'turn') {
403
+ this.clientIds.add(row.clientId)
404
+ this.turns.push({ threadId: row.threadId, sessionId: row.sessionId, clientId: row.clientId, at: row.at, query: row.query.slice(0, MEMORY_QUERY_CHARS), answerHead: row.answerHead, messageNo: row.messageNo })
405
+ if (this.turns.length > SEARCH_MEMORY_TURNS) this.turns.splice(0, this.turns.length - SEARCH_MEMORY_TURNS)
406
+ const summary = this.threads.get(row.threadId)
407
+ if (summary) {
408
+ summary.asks += 1
409
+ summary.segments = Math.max(summary.segments, row.segment)
410
+ if (!summary.title) summary.title = oneLine(row.query, 80)
411
+ }
412
+ if (this.current?.id === row.threadId) {
413
+ this.current = {
414
+ ...this.current,
415
+ asks: this.current.asks + 1,
416
+ segments: Math.max(this.current.segments, row.segment),
417
+ lastAskAt: row.at,
418
+ sessionId: row.sessionId,
419
+ nativeSessionId: row.native ?? (row.sessionId === this.current.sessionId ? this.current.nativeSessionId : null),
420
+ provider: row.provider ?? this.current.provider,
421
+ }
422
+ }
423
+ return
424
+ }
425
+ if (row.kind === 'close') {
426
+ const summary = this.threads.get(row.threadId)
427
+ if (summary) summary.endedAt = row.at
428
+ }
429
+ }
430
+
431
+ private appendRaw(text: string): void {
432
+ const fd = openSync(this.ledgerFile, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), 0o600)
433
+ try {
434
+ fchmodSync(fd, 0o600)
435
+ writeSync(fd, text)
436
+ fsyncSync(fd)
437
+ } finally { closeSync(fd) }
438
+ }
439
+ /** Ledger first, then memory, then the snapshot: a crash between them leaves a ledger the next open rebuilds from. */
440
+ private append(...rows: LedgerRow[]): void {
441
+ this.appendRaw(rows.map(r => JSON.stringify(r)).join('\n') + '\n')
442
+ for (const row of rows) this.apply(row)
443
+ this.ledgerRows += rows.length
444
+ this.writeState()
445
+ }
446
+
447
+ // ── reading ─────────────────────────────────────────
448
+ private evaluate(): { reason: NextReason; tokens: number | null } {
449
+ const t = this.current
450
+ if (!t.sessionId) return { reason: t.from ? 'manual' : 'empty', tokens: null }
451
+ const reading = (this.deps.context ?? claudeContextReader())(t.sessionId, t.nativeSessionId)
452
+ if (!reading.found) return { reason: 'missing_session', tokens: null }
453
+ if (reading.tokens !== null && reading.tokens > this.cap()) return { reason: 'size', tokens: reading.tokens }
454
+ return { reason: 'continue', tokens: reading.tokens }
455
+ }
456
+
457
+ private turnsOf(threadId: string): MemoryTurn[] { return this.turns.filter(t => t.threadId === threadId) }
458
+
459
+ /** What a fresh session would carry for the thread as it is now. */
460
+ private digestFor(thread: CurrentThread): string | null {
461
+ let turns = this.turnsOf(thread.id)
462
+ if (!turns.length && thread.from) turns = this.turnsOf(thread.from)
463
+ const recent = turns.slice(-DIGEST_TURNS).map(t => ({
464
+ query: t.query,
465
+ answerHead: t.answerHead || this.safeAnswerFor(t) || '',
466
+ messageNo: t.messageNo,
467
+ }))
468
+ const since = Math.min(Date.parse(thread.startedAt) || this.now(), this.now() - DAY_MS)
469
+ let drops: DigestDrop[] = []
470
+ try { drops = this.deps.drops?.(new Date(since).toISOString(), DIGEST_DROPS) ?? [] } catch { drops = [] }
471
+ // The reader answers newest first; the digest reads oldest first.
472
+ return buildDigest(recent, drops.slice(0, DIGEST_DROPS).reverse())
473
+ }
474
+ private safeAnswerFor(t: MemoryTurn): string | undefined {
475
+ try { return this.deps.answerFor?.(t.sessionId, t.clientId, t.messageNo)?.slice(0, ANSWER_HEAD_CHARS) } catch { return undefined }
476
+ }
477
+
478
+ view(): CosThreadAnswer {
479
+ const { reason, tokens } = this.evaluate()
480
+ return this.answer(reason, tokens)
481
+ }
482
+ private answer(reason: NextReason, tokens: number | null): CosThreadAnswer {
483
+ const t = this.current
484
+ const proceed = reason === 'continue'
485
+ return {
486
+ protocol: COS_THREAD_PROTOCOL,
487
+ capTokens: this.cap(),
488
+ thread: { id: t.id, startedAt: t.startedAt, lastAskAt: t.lastAskAt, asks: t.asks, segments: t.segments, sessionId: t.sessionId, provider: t.provider, contextTokens: tokens },
489
+ next: proceed
490
+ ? { mode: 'continue', sessionId: t.sessionId, reason, digest: null }
491
+ : { mode: 'fresh', sessionId: null, reason, digest: this.digestFor(t) },
492
+ }
493
+ }
494
+
495
+ // ── writing ─────────────────────────────────────────
496
+ /** New thread: the current one closes (rollover `manual` with what it would carry), a new one starts with no session. */
497
+ startNew(): CosThreadAnswer {
498
+ const old = this.current
499
+ const at = this.iso()
500
+ const digest = this.digestFor(old)
501
+ // An empty thread passes on what it was carrying, so pressing New twice never loses the summary.
502
+ const from = old.asks > 0 ? old.id : old.from ?? undefined
503
+ this.append(
504
+ { kind: 'rollover', threadId: old.id, at, reason: 'manual', fromSessionId: old.sessionId, digest },
505
+ { kind: 'close', threadId: old.id, at, reason: 'manual' },
506
+ { kind: 'thread', id: this.newId(), at, ...(from ? { from } : {}) },
507
+ )
508
+ return this.answer('manual', null)
509
+ }
510
+
511
+ /** One Ask. Idempotent on clientId. A different session than the thread's is a rollover onto a new segment. */
512
+ recordTurn(input: CosThreadTurnInput): CosThreadAnswer {
513
+ if (this.clientIds.has(input.clientId)) return this.view()
514
+ const t = this.current
515
+ const at = this.iso()
516
+ const rows: LedgerRow[] = []
517
+ let segment = Math.max(t.segments, 1)
518
+ if (t.sessionId !== null && t.sessionId !== input.sessionId) {
519
+ const { reason } = this.evaluate()
520
+ rows.push({ kind: 'rollover', threadId: t.id, at, reason, fromSessionId: t.sessionId, digest: this.digestFor(t) })
521
+ segment = t.segments + 1
522
+ }
523
+ let native: string | undefined
524
+ try { native = this.deps.nativeFor?.(input.sessionId) } catch { native = undefined }
525
+ rows.push({
526
+ kind: 'turn', threadId: t.id, segment, sessionId: input.sessionId, clientId: input.clientId, at,
527
+ query: input.query, answerHead: (input.answer ?? '').slice(0, ANSWER_HEAD_CHARS), messageNo: input.messageNo ?? null,
528
+ provider: input.provider ?? null, ...(native ? { native } : {}),
529
+ })
530
+ this.append(...rows)
531
+ return this.view()
532
+ }
533
+
534
+ search(q: string, limit: number): CosThreadSearchAnswer {
535
+ const needle = q.toLowerCase()
536
+ const hits: CosThreadSearchHit[] = []
537
+ let scanned = 0
538
+ for (let i = this.turns.length - 1; i >= 0 && hits.length < limit; i--) {
539
+ const t = this.turns[i]!
540
+ scanned += 1
541
+ if (t.query.toLowerCase().includes(needle) || t.answerHead.toLowerCase().includes(needle)) {
542
+ hits.push({ threadId: t.threadId, sessionId: t.sessionId, at: t.at, query: t.query, answerHead: t.answerHead, messageNo: t.messageNo })
543
+ }
544
+ }
545
+ return { hits, scanned }
546
+ }
547
+
548
+ /** Newest first. A thread with no asks is listed only while it is the current one. */
549
+ list(limit: number): { threads: CosThreadSummary[] } {
550
+ const out: CosThreadSummary[] = []
551
+ for (let i = this.order.length - 1; i >= 0 && out.length < limit; i--) {
552
+ const s = this.threads.get(this.order[i]!)
553
+ if (!s || (s.asks === 0 && s.id !== this.current.id)) continue
554
+ out.push({ ...s })
555
+ }
556
+ return { threads: out }
557
+ }
558
+ }
559
+
560
+ function isRow(row: unknown): row is LedgerRow {
561
+ if (!row || typeof row !== 'object') return false
562
+ const r = row as Record<string, unknown>
563
+ if (typeof r.at !== 'string') return false
564
+ if (r.kind === 'thread') return typeof r.id === 'string'
565
+ if (r.kind === 'turn') return typeof r.threadId === 'string' && typeof r.sessionId === 'string' && typeof r.clientId === 'string'
566
+ && typeof r.query === 'string' && typeof r.answerHead === 'string' && Number.isInteger(r.segment)
567
+ if (r.kind === 'rollover' || r.kind === 'close') return typeof r.threadId === 'string'
568
+ return false
569
+ }
570
+
571
+ /** Opens the store once, on the first request that needs it (Drop it on). Null while it cannot open. */
572
+ export function lazyCosThread(open: () => CosThread): () => CosThread | null {
573
+ let thread: CosThread | null = null
574
+ return () => {
575
+ if (thread) return thread
576
+ try { thread = open() } catch (e) {
577
+ console.error('[cos-thread] store unavailable:', e instanceof CosThreadError ? e.code : e instanceof Error ? e.name : 'error')
578
+ return null
579
+ }
580
+ return thread
581
+ }
582
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 6.99.13, Drop it with files: a drop's files live in the media store, the same store an Ask's files use.
3
+ *
4
+ * The web composer uploads first (POST /api/media for a photo, POST /api/media/file for anything else), which leaves a
5
+ * `staged` record that the store expires after STAGED_TTL_MS (4 hours). A drop pins its uploads (MediaStore.pinForDrop):
6
+ * the record becomes `associated` with the drop's id, which no TTL and no GC pass touches, so the bytes stay under
7
+ * data/media/assets/<id>/ for as long as the drop holds them, and open through GET /api/media/:id/content like any
8
+ * Ask's files. Undo removes them (MediaStore.unpinDrop). Nothing is copied and nothing leaves the server's data dir.
9
+ *
10
+ * Errors become DropErrors whose words say what happened and what to do.
11
+ */
12
+ import { MediaStoreError, type MediaStore } from './media-store.js'
13
+ import { DropError, type DropAttachmentPins } from './drop-service.js'
14
+ import { DROP_LIMITS, type DropAttachment } from './drop-store.js'
15
+ import { mediaCategoryOf, type MediaAttachmentRef } from '../../shared/media-attachment.js'
16
+
17
+ const REFUSALS: Record<string, { code: string; status: number; message: string }> = {
18
+ media_not_found: { code: 'attachment_not_found', status: 404, message: 'A file is no longer on this Mac. Add it again, then Drop.' },
19
+ media_deleted: { code: 'attachment_not_found', status: 404, message: 'A file is no longer on this Mac. Add it again, then Drop.' },
20
+ media_expired: { code: 'attachment_expired', status: 410, message: 'A file expired before it was dropped (uploads wait 4 hours). Add it again, then Drop.' },
21
+ media_conflict: { code: 'attachment_in_use', status: 409, message: 'A file was already sent with something else. Add it again to drop it.' },
22
+ media_unavailable: { code: 'attachment_unavailable', status: 503, message: 'This Mac could not read a file. Add it again, then Drop.' },
23
+ }
24
+
25
+ /** The name a person sees for a file: its own label, else what kind it is. Control characters never reach the store. */
26
+ export function attachmentName(ref: MediaAttachmentRef): string {
27
+ const label = (ref.label ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, DROP_LIMITS.attachmentName)
28
+ if (label) return label
29
+ const category = mediaCategoryOf(ref)
30
+ return category === 'image' ? 'Photo' : category === 'video' ? 'Video' : 'File'
31
+ }
32
+
33
+ /** `bytes` is the stored size (a photo's public ref carries none); the ref's own count when the record has none. */
34
+ export function toDropAttachment(ref: MediaAttachmentRef, bytes?: number): DropAttachment {
35
+ const size = [bytes, ref.bytes].find(n => Number.isSafeInteger(n) && (n as number) > 0) ?? 0
36
+ return { id: ref.id, name: attachmentName(ref), mime: ref.mime, size }
37
+ }
38
+
39
+ export function mediaDropAttachments(store: () => MediaStore): DropAttachmentPins {
40
+ return {
41
+ async pin(ids, dropId) {
42
+ try {
43
+ const media = store()
44
+ return (await media.pinForDrop(ids, dropId)).map(ref => toDropAttachment(ref, media.getRecord(ref.id)?.bytes))
45
+ } catch (e) {
46
+ const refusal = e instanceof MediaStoreError ? REFUSALS[e.code] : undefined
47
+ if (refusal) throw new DropError(refusal.code, refusal.status, refusal.message)
48
+ throw new DropError('attachment_unavailable', 503, REFUSALS.media_unavailable.message)
49
+ }
50
+ },
51
+ async unpin(ids, dropId) {
52
+ return store().unpinDrop(ids, dropId)
53
+ },
54
+ }
55
+ }