@gotcos/glasses-server 6.99.12 → 6.99.20
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 +124 -0
- package/package.json +1 -1
- package/server/index.ts +44 -2
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-service.ts +104 -13
- package/server/lib/drop-settings.ts +13 -2
- package/server/lib/drop-store.ts +38 -2
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +54 -2
- package/server/lib/permission-broker.ts +6 -4
- package/server/lib/phone-recording.ts +1 -1
- package/server/lib/ping-sources.ts +6 -2
- package/server/lib/prep-runtime.ts +915 -0
- package/server/lib/prep-store.ts +226 -0
- package/server/lib/stt-resolver.ts +26 -2
- package/server/lib/transcript-cleanup.ts +11 -2
- package/server/lib/work-orchestrator/runtime.ts +8 -2
- package/server/routes/ask-work-plan.ts +114 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/drops.ts +4 -2
- package/server/routes/health.ts +8 -0
- package/server/routes/permission-broker.ts +2 -2
- package/server/routes/prep.ts +81 -0
- package/server/routes/transcribe-stream.ts +9 -1
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/control.css +13 -0
- package/server/web-control/panes/cos-chat.css +39 -0
- package/server/web-control/panes/cos-chat.js +799 -41
- package/server/web-control/panes/meetings.css +91 -4
- package/server/web-control/panes/meetings.js +585 -58
- package/server/web-control/panes/messages.js +25 -18
- package/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
|
@@ -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
|
+
}
|