@gotcos/glasses-server 6.2.0 → 6.3.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 +55 -0
- package/README.md +2 -0
- package/bin/cli.cjs +12 -0
- package/package.json +1 -1
- package/server/index.ts +44 -2
- package/server/lib/archive.ts +7 -3
- package/server/lib/claude-bridge.ts +5 -4
- package/server/lib/codex-run-ledger.ts +5 -0
- package/server/lib/launch-dir.ts +29 -0
- package/server/routes/archive.ts +79 -0
- package/server/routes/message-ref.ts +190 -0
- package/server/routes/openai-compat.ts +2 -2
- package/server/routes/sessions.ts +270 -0
- package/server/routes/transcribe-stream.ts +6 -1
- package/shared/model-preference.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.3.1
|
|
4
|
+
|
|
5
|
+
Security + robustness hardening on the 6.3.0 archive routes, from a 3-agent QA pass. (6.3.0 was never published; 6.3.1 is the first release of the expanded route set.)
|
|
6
|
+
|
|
7
|
+
- **SECURITY — path traversal blocked.** The new `:date` archive routes fed the param straight into `<dir>/${date}.json`, so an encoded traversal (`/api/archive/..%2F..%2Fetc%2Fhosts`) could read arbitrary `*.json` on the host (and rename-corrupt one via the quarantine path). Auth+IP gated, but a real exposure on a shared LAN/meshnet. Fixed: `archiveRouter.param('date', …)` enforces `^\d{4}-\d{2}-\d{2}$` on every `:date` route before any fs access; defense-in-depth guard in `readArchiveChatNumbered`. Verified: traversal/bad-format → 400, valid dates → 200.
|
|
8
|
+
- **Reference date label (US evenings).** Live-session `reference message N` stamped the date with UTC, labeling an evening reference with tomorrow's date. Now `localDay()`.
|
|
9
|
+
- **Malformed day file no longer wipes History.** A valid-JSON wrong-shape day file (no `chats[]`) 500'd the readers and dropped `listArchiveDates` into its catch, hiding all history. `loadArchive` coerces `chats` to `[]`; the bad day lists as 0 chats.
|
|
10
|
+
- **Thrift/cosmetic:** `/api/archive/now` passes `skipLLM:true` (no surprise LLM spend on a public manual snapshot); stale path comment + unused `__dirname` removed from `lib/archive.ts`.
|
|
11
|
+
|
|
12
|
+
## 6.3.0
|
|
13
|
+
|
|
14
|
+
Message History, cross-day references, and history recovery for public installs.
|
|
15
|
+
These features previously required a full COS server; now `npx @gotcos/glasses-server`
|
|
16
|
+
exposes them too, so the G2 app's Message History and "reference message N" work
|
|
17
|
+
on a vanilla install.
|
|
18
|
+
|
|
19
|
+
- **Message History** — the archive routes (`/api/archive`, `/api/archive/:date/chats`,
|
|
20
|
+
`/api/archive/:date/chats/:i/messages`, `/api/archive/:date/messages`, `/api/archive/now`)
|
|
21
|
+
are now served. The daily archive-mirror (already in this package) writes prior-day
|
|
22
|
+
sessions to disk; these routes browse them. Each day row shows chat count + topic.
|
|
23
|
+
- **Cross-day "reference message N"** — new `/api/message/:num` resolves a permanent
|
|
24
|
+
message number across live sessions then day archives (newest-first), and
|
|
25
|
+
`/api/message-counter` publishes the numbering ceiling so a fresh/cleared client
|
|
26
|
+
never reuses a number. Message numbers were already stored (`globalMsgNum`); this
|
|
27
|
+
makes them resolvable.
|
|
28
|
+
- **History recovery** — session routes (`/api/sessions/today/all-messages`,
|
|
29
|
+
`/api/sessions/:id/messages`, recent-sessions index, context-break, end-session)
|
|
30
|
+
let the app restore recent history and open archived chats.
|
|
31
|
+
|
|
32
|
+
No change to the public-safe model curation (Sonnet default, no pinned/unreleased
|
|
33
|
+
model ids) or the core query/voice/display paths. Typecheck clean; new routes
|
|
34
|
+
smoke-tested (message-counter, archive list, message lookup).
|
|
35
|
+
|
|
36
|
+
## 6.2.1
|
|
37
|
+
|
|
38
|
+
Foolproofing release — driven by an adversarial onboarding QA pass.
|
|
39
|
+
|
|
40
|
+
- **The server now prints URLs the phone can actually use.** Boot output lists
|
|
41
|
+
your real addresses (`http://100.x.x.x:3141` labeled Tailscale, LAN IPs labeled
|
|
42
|
+
same-Wi-Fi) instead of only the un-pasteable bind address `0.0.0.0`.
|
|
43
|
+
- **Auto-generated API tokens survive restarts.** First boot saves the token to
|
|
44
|
+
`~/.cos-glasses/.env`, so re-running the server no longer silently rotates the
|
|
45
|
+
credential your app already saved (the "worked yesterday, 401 today" trap).
|
|
46
|
+
- **Starter-Kit COS inheritance is real now.** Run `npx @gotcos/glasses-server`
|
|
47
|
+
from your COS folder and glasses chat loads its brain: the launcher records
|
|
48
|
+
your launch directory, and when it contains `.cos/manifest.json`, `AGENTS.md`,
|
|
49
|
+
or `CLAUDE.md`, Claude/Codex spawn there (explicit `COS_SCRIPTS_DIR` still wins).
|
|
50
|
+
- **Transfer-integrity report actually surfaces.** 6.2.0 recorded lost chunks but
|
|
51
|
+
never returned them; the offline-session `finalize` response now includes
|
|
52
|
+
`transferIntegrity` (received/expected/missing/completeness) and a gap-aware
|
|
53
|
+
`transcript` with inline `[… audio gap …]` markers.
|
|
54
|
+
- **One default model everywhere: Sonnet.** The query router, the OpenAI-compat
|
|
55
|
+
surface, and CLI pre-warm all default to Sonnet (was a mix of Opus and Haiku).
|
|
56
|
+
Set `COS_G2_DEFAULT_MODEL` to override; per-query picks unchanged.
|
|
57
|
+
|
|
3
58
|
## 6.2.0
|
|
4
59
|
|
|
5
60
|
Reliability release — ports the hardening the full COS Glasses app shipped in June.
|
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
44
44
|
## What it does
|
|
45
45
|
|
|
46
46
|
- Ask anything, get a streamed answer on the lens (`/api/query`, `/v1/chat/completions`)
|
|
47
|
+
- Message History + cross-day "reference message N" — your chats are archived by day
|
|
48
|
+
and every message keeps a permanent number you can recall (`/api/archive`, `/api/message/:num`)
|
|
47
49
|
- Live voice capture + transcription during meetings
|
|
48
50
|
- Local whisper.cpp transcription (free) with OpenAI fallback (optional)
|
|
49
51
|
- Tasks / calendar / people context **if** you run the
|
package/bin/cli.cjs
CHANGED
|
@@ -13,6 +13,12 @@ const { homedir } = require('os')
|
|
|
13
13
|
const PKG_ROOT = resolve(__dirname, '..')
|
|
14
14
|
const CONFIG_DIR = join(homedir(), '.cos-glasses')
|
|
15
15
|
|
|
16
|
+
// Record where the user ran `npx @gotcos/glasses-server` from. The server spawns
|
|
17
|
+
// with cwd = PKG_ROOT (the npx cache), so without this the user's Starter-Kit COS
|
|
18
|
+
// (AGENTS.md / CLAUDE.md / .cos/) in their launch folder would never be seen.
|
|
19
|
+
// Chat spawns of claude/codex use this dir when it contains a COS brain.
|
|
20
|
+
if (!process.env.COS_LAUNCH_DIR) process.env.COS_LAUNCH_DIR = process.cwd()
|
|
21
|
+
|
|
16
22
|
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
17
23
|
const red = (s) => `\x1b[31m${s}\x1b[0m`
|
|
18
24
|
const yellow = (s) => `\x1b[33m${s}\x1b[0m`
|
|
@@ -185,6 +191,12 @@ if (!process.env.BIND_HOST) {
|
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
// Step 7: start the bundled server
|
|
194
|
+
try {
|
|
195
|
+
const ld = process.env.COS_LAUNCH_DIR
|
|
196
|
+
if (ld && (existsSync(join(ld, '.cos', 'manifest.json')) || existsSync(join(ld, 'AGENTS.md')) || existsSync(join(ld, 'CLAUDE.md')))) {
|
|
197
|
+
console.log(green(' ✓') + ` COS detected in ${dim(ld)} — glasses chat will load its brain`)
|
|
198
|
+
}
|
|
199
|
+
} catch { /* detection is best-effort */ }
|
|
188
200
|
console.log('')
|
|
189
201
|
console.log(dim(' Starting server...'))
|
|
190
202
|
console.log('')
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -7,7 +7,9 @@ import cors from 'cors'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { createServer as createHttpsServer } from 'node:https'
|
|
10
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
10
|
+
import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
|
|
11
|
+
import { networkInterfaces, homedir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
11
13
|
import { execSync } from 'node:child_process'
|
|
12
14
|
import { randomBytes } from 'node:crypto'
|
|
13
15
|
import { healthRouter } from './routes/health.js'
|
|
@@ -18,6 +20,9 @@ import { displayRouter } from './routes/display.js'
|
|
|
18
20
|
import { transcribeStreamRouter } from './routes/transcribe-stream.js'
|
|
19
21
|
import { openaiCompatRouter } from './routes/openai-compat.js'
|
|
20
22
|
import { openaiKeyRouter } from './routes/openai-key.js'
|
|
23
|
+
import { messageRefRouter } from './routes/message-ref.js'
|
|
24
|
+
import { archiveRouter } from './routes/archive.js'
|
|
25
|
+
import { sessionsRouter } from './routes/sessions.js'
|
|
21
26
|
import { prewarmContext } from './lib/context-builder.js'
|
|
22
27
|
import { preWarmCLI } from './lib/claude-bridge.js'
|
|
23
28
|
import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
|
|
@@ -43,6 +48,18 @@ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
|
|
|
43
48
|
const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
|
|
44
49
|
const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
|
|
45
50
|
process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
|
|
51
|
+
// Persist an auto-generated token to ~/.cos-glasses/.env so it SURVIVES restarts.
|
|
52
|
+
// Without this, every re-run mints a new token and the app's saved token starts
|
|
53
|
+
// returning 401 — the symptom looks like a broken app, not a rotated credential.
|
|
54
|
+
let API_TOKEN_PERSISTED = false
|
|
55
|
+
if (API_TOKEN_AUTO) {
|
|
56
|
+
try {
|
|
57
|
+
const envDir = join(homedir(), '.cos-glasses')
|
|
58
|
+
mkdirSync(envDir, { recursive: true })
|
|
59
|
+
appendFileSync(join(envDir, '.env'), `\n# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`)
|
|
60
|
+
API_TOKEN_PERSISTED = true
|
|
61
|
+
} catch { /* read-only home — token stays per-session */ }
|
|
62
|
+
}
|
|
46
63
|
|
|
47
64
|
// Server metrics — shared with /api/health for monitoring
|
|
48
65
|
export const serverMetrics = {
|
|
@@ -117,6 +134,11 @@ app.use('/api', transcribeRouter)
|
|
|
117
134
|
app.use('/api', displayRouter)
|
|
118
135
|
app.use('/api', transcribeStreamRouter)
|
|
119
136
|
app.use('/api', openaiKeyRouter)
|
|
137
|
+
// v6.3.0 — Message History, cross-day 'reference message N', and history
|
|
138
|
+
// recovery for public npx users (previously full-COS-server only).
|
|
139
|
+
app.use('/api', messageRefRouter)
|
|
140
|
+
app.use('/api', archiveRouter)
|
|
141
|
+
app.use('/api', sessionsRouter)
|
|
120
142
|
|
|
121
143
|
// OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
|
|
122
144
|
// Mounted at root — routes are /v1/chat/completions and /v1/models
|
|
@@ -178,11 +200,31 @@ app.listen(PORT, BIND_HOST, () => {
|
|
|
178
200
|
console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
|
|
179
201
|
console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
|
|
180
202
|
|
|
203
|
+
// Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
|
|
204
|
+
// not paste-able — enumerate real interfaces and label the Tailscale one.
|
|
205
|
+
try {
|
|
206
|
+
const nets = networkInterfaces()
|
|
207
|
+
const addrs: Array<{ ip: string; label: string }> = []
|
|
208
|
+
for (const [name, infos] of Object.entries(nets)) {
|
|
209
|
+
for (const info of infos ?? []) {
|
|
210
|
+
if (info.family !== 'IPv4' || info.internal) continue
|
|
211
|
+
const isTailscale = info.address.startsWith('100.') || name.startsWith('utun') || name.startsWith('tailscale')
|
|
212
|
+
addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (addrs.length > 0) {
|
|
216
|
+
addrs.sort((a, b) => Number(b.label.startsWith('Tailscale')) - Number(a.label.startsWith('Tailscale')))
|
|
217
|
+
console.log('')
|
|
218
|
+
console.log('[COS API] Server URL for the COS Glasses app:')
|
|
219
|
+
for (const a of addrs) console.log(`[COS API] http://${a.ip}:${PORT} (${a.label})`)
|
|
220
|
+
}
|
|
221
|
+
} catch { /* interface enumeration is best-effort */ }
|
|
222
|
+
|
|
181
223
|
// Print the full API token when auto-generated — the user pastes it into the app.
|
|
182
224
|
if (API_TOKEN_AUTO) {
|
|
183
225
|
console.log('')
|
|
184
226
|
console.log(`[COS API] API Token: ${API_TOKEN}`)
|
|
185
|
-
console.log('[COS API] ^ paste this into the COS Glasses app (set COS_API_TOKEN in .env for a fixed token)')
|
|
227
|
+
console.log('[COS API] ^ paste this into the COS Glasses app' + (API_TOKEN_PERSISTED ? ' — saved to ~/.cos-glasses/.env so it stays the same across restarts' : ' (set COS_API_TOKEN in .env for a fixed token)'))
|
|
186
228
|
console.log('')
|
|
187
229
|
}
|
|
188
230
|
|
package/server/lib/archive.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Daily archive system — persists conversation history beyond session TTL
|
|
2
|
-
// Archives are stored as JSON files per day in
|
|
2
|
+
// Archives are stored as JSON files per day in ~/.cos-glasses/data/archive/
|
|
3
3
|
// Each day's archive contains one or more "chats" (split by context breaks)
|
|
4
4
|
// Summaries are generated via `claude -p --model sonnet`, budget-capped per day.
|
|
5
5
|
|
|
@@ -15,7 +15,6 @@ import { consumeArchiveLLMBudget } from './archive-budget.js'
|
|
|
15
15
|
const execAsync = promisify(exec)
|
|
16
16
|
import type { Exchange } from './conversation.js'
|
|
17
17
|
|
|
18
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
18
|
import { dataPath } from './data-dir.js'
|
|
20
19
|
const ARCHIVE_DIR = dataPath('archive')
|
|
21
20
|
|
|
@@ -87,7 +86,12 @@ export function loadArchive(date: string): DailyArchive | null {
|
|
|
87
86
|
return null
|
|
88
87
|
}
|
|
89
88
|
if (result.status === 'missing') return null
|
|
90
|
-
|
|
89
|
+
// Defense: a valid-JSON but wrong-shape day file (no chats[]) would make the
|
|
90
|
+
// readers throw 500 AND drop listArchiveDates into its catch → the whole
|
|
91
|
+
// Message History list vanishes on one bad file. Coerce to an empty day.
|
|
92
|
+
const data = result.data
|
|
93
|
+
if (data && !Array.isArray(data.chats)) data.chats = []
|
|
94
|
+
return data
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
function saveArchive(archive: DailyArchive): void {
|
|
@@ -12,11 +12,12 @@ import { join } from 'node:path'
|
|
|
12
12
|
import { appendFileSync } from 'node:fs'
|
|
13
13
|
import crypto from 'node:crypto'
|
|
14
14
|
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
15
|
+
import { cosBrainDir } from './launch-dir.js'
|
|
15
16
|
import { logTokenAudit } from './token-audit.js'
|
|
16
17
|
import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
|
|
17
18
|
import { getHistory, addExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
|
|
18
19
|
import { notifySessionStart, notifyExchange } from './telegram-notify.js'
|
|
19
|
-
import { isClaudeModel, type ClaudeModelPreference } from '../../shared/model-preference.js'
|
|
20
|
+
import { isClaudeModel, DEFAULT_MODEL, type ClaudeModelPreference } from '../../shared/model-preference.js'
|
|
20
21
|
import {
|
|
21
22
|
finishClaudeRun,
|
|
22
23
|
getClaudeEffortLevel,
|
|
@@ -182,7 +183,7 @@ export async function preWarmCLI(): Promise<void> {
|
|
|
182
183
|
|
|
183
184
|
const proc = spawn('claude', [
|
|
184
185
|
'-p',
|
|
185
|
-
'--model',
|
|
186
|
+
'--model', DEFAULT_MODEL, // Must match default query model — --resume inherits session model
|
|
186
187
|
'--effort', getClaudeEffortLevel(),
|
|
187
188
|
'--output-format', 'stream-json',
|
|
188
189
|
'--verbose',
|
|
@@ -191,7 +192,7 @@ export async function preWarmCLI(): Promise<void> {
|
|
|
191
192
|
], {
|
|
192
193
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
193
194
|
env,
|
|
194
|
-
cwd: COS_SCRIPTS_DIR ?? process.cwd(),
|
|
195
|
+
cwd: COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd(),
|
|
195
196
|
})
|
|
196
197
|
|
|
197
198
|
let buffer = ''
|
|
@@ -419,7 +420,7 @@ export async function callClaudeStreaming(
|
|
|
419
420
|
// Strip CLAUDECODE env var so claude -p doesn't think it's nested
|
|
420
421
|
const env = { ...process.env }
|
|
421
422
|
delete env.CLAUDECODE
|
|
422
|
-
const cliCwd = COS_SCRIPTS_DIR ?? process.cwd()
|
|
423
|
+
const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
|
|
423
424
|
const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
|
|
424
425
|
const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
|
|
425
426
|
const wallMax = isExtendedQuery(query) ? WALL_MAX_EXTENDED_MS : defaultWallMax
|
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
|
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
4
|
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
5
|
+
import { cosBrainDir } from './launch-dir.js'
|
|
5
6
|
import { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
|
|
6
7
|
import {
|
|
7
8
|
CODEX_HIGH_REASONING_EFFORT,
|
|
@@ -80,6 +81,10 @@ export function getCodexExecutionCwd(): string {
|
|
|
80
81
|
const configured = process.env.CODEX_GLASSES_WORKDIR?.trim()
|
|
81
82
|
if (configured) return resolve(configured)
|
|
82
83
|
if (COS_SCRIPTS_DIR) return resolve(COS_SCRIPTS_DIR, '..', '..')
|
|
84
|
+
// A Starter-Kit COS in the directory the user launched npx from — Codex
|
|
85
|
+
// loads its AGENTS.md brain natively when run there.
|
|
86
|
+
const brain = cosBrainDir()
|
|
87
|
+
if (brain) return brain
|
|
83
88
|
// Last resort when neither CODEX_GLASSES_WORKDIR nor COS_SCRIPTS_DIR is set:
|
|
84
89
|
// the server's own working dir (codex glasses is an optional, env-configured feature).
|
|
85
90
|
return process.cwd()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// COS brain auto-detection for the "glasses inherit your COS" promise.
|
|
2
|
+
//
|
|
3
|
+
// The npx launcher (bin/cli.cjs) records the directory the user ran `npx
|
|
4
|
+
// @gotcos/glasses-server` from as COS_LAUNCH_DIR before re-spawning the server
|
|
5
|
+
// with cwd = the package root. If that launch directory contains a COS brain
|
|
6
|
+
// (a Starter-Kit scaffold: .cos/manifest.json, AGENTS.md, or CLAUDE.md), chat
|
|
7
|
+
// spawns of claude/codex use IT as their working directory — so the CLIs load
|
|
8
|
+
// the user's brain exactly as they would in a terminal session in that folder.
|
|
9
|
+
//
|
|
10
|
+
// Precedence stays: COS_SCRIPTS_DIR (full COS pipeline) > detected brain dir >
|
|
11
|
+
// process.cwd(). Explicit config always wins.
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { join, resolve } from 'node:path'
|
|
14
|
+
|
|
15
|
+
let cached: string | null | undefined
|
|
16
|
+
|
|
17
|
+
/** The user's launch directory IF it contains a COS brain; otherwise null. */
|
|
18
|
+
export function cosBrainDir(): string | null {
|
|
19
|
+
if (cached !== undefined) return cached
|
|
20
|
+
const raw = process.env.COS_LAUNCH_DIR?.trim()
|
|
21
|
+
if (!raw) { cached = null; return cached }
|
|
22
|
+
const dir = resolve(raw)
|
|
23
|
+
const hasBrain =
|
|
24
|
+
existsSync(join(dir, '.cos', 'manifest.json')) ||
|
|
25
|
+
existsSync(join(dir, 'AGENTS.md')) ||
|
|
26
|
+
existsSync(join(dir, 'CLAUDE.md'))
|
|
27
|
+
cached = hasBrain ? dir : null
|
|
28
|
+
return cached
|
|
29
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Archive endpoints — daily conversation archive for glasses history browser
|
|
2
|
+
import { Router } from 'express'
|
|
3
|
+
import { listArchiveDates, loadArchive, getArchiveChats, getArchiveDayMessages, appendToArchive } from '../lib/archive.js'
|
|
4
|
+
import { getArchiveChatMessagesNumbered } from './message-ref.js'
|
|
5
|
+
import { getActiveSessions } from '../lib/conversation.js'
|
|
6
|
+
|
|
7
|
+
export const archiveRouter = Router()
|
|
8
|
+
|
|
9
|
+
// v5.15.6 / pkg v6.3.1 — SECURITY: :date is used to build filesystem paths
|
|
10
|
+
// (loadArchive/getArchiveChats/getArchiveDayMessages/getArchiveChatMessagesNumbered
|
|
11
|
+
// all resolve `<dir>/${date}.json`). Without validation, an encoded traversal
|
|
12
|
+
// (e.g. /api/archive/..%2F..%2Fetc%2Fhosts) reads/renames arbitrary *.json on
|
|
13
|
+
// the host. Validate the segment as a strict YYYY-MM-DD once for every :date
|
|
14
|
+
// route before any fs access.
|
|
15
|
+
archiveRouter.param('date', (req, res, next, date) => {
|
|
16
|
+
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
17
|
+
res.status(400).json({ error: 'Invalid date' })
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
next()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// GET /api/archive — list all archive dates with summaries
|
|
24
|
+
archiveRouter.get('/archive', (_req, res) => {
|
|
25
|
+
const archives = listArchiveDates()
|
|
26
|
+
res.json({ archives })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// POST /api/archive/now — snapshot active sessions into today's archive (non-destructive)
|
|
30
|
+
archiveRouter.post('/archive/now', async (_req, res) => {
|
|
31
|
+
const activeSessions = getActiveSessions()
|
|
32
|
+
if (activeSessions.length === 0) {
|
|
33
|
+
res.json({ archived: 0, date: new Date().toISOString().slice(0, 10) })
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const todayDate = new Date().toISOString().slice(0, 10)
|
|
38
|
+
let archived = 0
|
|
39
|
+
for (const session of activeSessions) {
|
|
40
|
+
await appendToArchive(todayDate, session, { skipLLM: true }) // public thrift: no surprise LLM spend on a manual snapshot
|
|
41
|
+
archived++
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
res.json({ archived, date: todayDate })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// GET /api/archive/:date — full daily archive
|
|
48
|
+
archiveRouter.get('/archive/:date', (req, res) => {
|
|
49
|
+
const archive = loadArchive(req.params.date)
|
|
50
|
+
if (!archive) {
|
|
51
|
+
res.status(404).json({ error: 'Archive not found for date' })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
res.json(archive)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// GET /api/archive/:date/chats — chat summaries for a day
|
|
58
|
+
archiveRouter.get('/archive/:date/chats', (req, res) => {
|
|
59
|
+
const chats = getArchiveChats(req.params.date)
|
|
60
|
+
res.json({ chats })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// GET /api/archive/:date/chats/:index/messages — paired Q&A for a specific chat
|
|
64
|
+
archiveRouter.get('/archive/:date/chats/:index/messages', (req, res) => {
|
|
65
|
+
const index = parseInt(req.params.index, 10)
|
|
66
|
+
if (isNaN(index)) {
|
|
67
|
+
res.status(400).json({ error: 'Invalid chat index' })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
// v5.15.1 — numbered form so the browser can show the durable Msg #N
|
|
71
|
+
const messages = getArchiveChatMessagesNumbered(req.params.date, index)
|
|
72
|
+
res.json({ messages })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// GET /api/archive/:date/messages — all messages for a day (flat)
|
|
76
|
+
archiveRouter.get('/archive/:date/messages', (req, res) => {
|
|
77
|
+
const messages = getArchiveDayMessages(req.params.date)
|
|
78
|
+
res.json({ messages })
|
|
79
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Global message reference resolution (v5.15.0) — the server half of
|
|
2
|
+
// "reference message N" across days. Numbers are stamped at exchange time
|
|
3
|
+
// (client-sent, stored via conversation.addExchange) and persist durably in
|
|
4
|
+
// the day archives; this router resolves a number the client no longer holds
|
|
5
|
+
// in its local list, and publishes the numbering ceiling so a cleared or
|
|
6
|
+
// fresh client continues the sequence instead of reusing numbers.
|
|
7
|
+
//
|
|
8
|
+
// GET /api/message/:num → { globalMsgNum, date, query, response } (404 when unknown)
|
|
9
|
+
// GET /api/message-counter → { max }
|
|
10
|
+
//
|
|
11
|
+
// Resolution order (per the prompt-queue/archive plan): live in-memory
|
|
12
|
+
// sessions first (covers the mirror's 15-minute lag), then day archives
|
|
13
|
+
// newest-first. Day files are read as plain data — their write path belongs
|
|
14
|
+
// to the archive workstream and is not touched here.
|
|
15
|
+
import { Router } from 'express'
|
|
16
|
+
import { readdirSync, readFileSync } from 'fs'
|
|
17
|
+
import { resolve } from 'path'
|
|
18
|
+
import { getActiveSessions } from '../lib/conversation.js'
|
|
19
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
20
|
+
import { localDay } from '../lib/local-day.js'
|
|
21
|
+
|
|
22
|
+
// v6.3.0 — read archives from the SAME persistent location the archive-mirror
|
|
23
|
+
// writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
|
|
24
|
+
// dir. The app repo uses server/data/archive; the public package uses dataPath,
|
|
25
|
+
// so this file must match the package's lib/archive.ts, or npx users' cross-day
|
|
26
|
+
// references + archive-chat detail read an empty/nonexistent directory.
|
|
27
|
+
const ARCHIVE_DIR = dataPath('archive')
|
|
28
|
+
|
|
29
|
+
export interface ResolvedGlobalMessage {
|
|
30
|
+
globalMsgNum: number
|
|
31
|
+
date: string
|
|
32
|
+
query: string
|
|
33
|
+
response: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ExchangeLike {
|
|
37
|
+
role?: string
|
|
38
|
+
content?: string
|
|
39
|
+
timestamp?: number
|
|
40
|
+
globalMsgNum?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Pair the stamped exchange with its other half: a user turn pairs forward
|
|
44
|
+
* to the next assistant turn; an assistant turn pairs backward. */
|
|
45
|
+
function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; response: string } {
|
|
46
|
+
const hit = exchanges[i]
|
|
47
|
+
const user = hit.role === 'user'
|
|
48
|
+
? hit
|
|
49
|
+
: [...exchanges.slice(0, i)].reverse().find((e) => e?.role === 'user')
|
|
50
|
+
const assistant = hit.role === 'assistant'
|
|
51
|
+
? hit
|
|
52
|
+
: exchanges.slice(i + 1).find((e) => e?.role === 'assistant')
|
|
53
|
+
return { query: user?.content ?? '', response: assistant?.content ?? '' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): ResolvedGlobalMessage | null {
|
|
57
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
58
|
+
if (exchanges[i]?.globalMsgNum !== num) continue
|
|
59
|
+
const { query, response } = pairExchange(exchanges, i)
|
|
60
|
+
return { globalMsgNum: num, date, query, response }
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve a global message number from the day archives, newest-first.
|
|
66
|
+
* Exported with an explicit dir for tests. */
|
|
67
|
+
export function resolveFromArchiveDir(dir: string, num: number): ResolvedGlobalMessage | null {
|
|
68
|
+
let files: string[] = []
|
|
69
|
+
try {
|
|
70
|
+
files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse()
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
for (const f of files) {
|
|
75
|
+
try {
|
|
76
|
+
const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
|
|
77
|
+
const chats = Array.isArray(day?.chats) ? day.chats : []
|
|
78
|
+
for (const chat of chats) {
|
|
79
|
+
const exchanges = Array.isArray(chat?.exchanges) ? chat.exchanges : []
|
|
80
|
+
const hit = scanExchanges(exchanges, num, typeof day?.date === 'string' ? day.date : f.slice(0, 10))
|
|
81
|
+
if (hit) return hit
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Unreadable/corrupt day file — skip; the archive workstream owns repair.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read a specific archived chat's paired Q&A messages WITH their durable
|
|
91
|
+
* global numbers (the archive-lib read path strips globalMsgNum; the browser
|
|
92
|
+
* needs it so "reference message N" is self-evident from the screen). Same
|
|
93
|
+
* user->next-assistant pairing as the lib; the pair's number is the user
|
|
94
|
+
* turn's stamp (falling back to the assistant's). Dir-param form for tests. */
|
|
95
|
+
export function readArchiveChatNumbered(
|
|
96
|
+
dir: string,
|
|
97
|
+
date: string,
|
|
98
|
+
chatIndex: number,
|
|
99
|
+
): Array<{ query: string; text: string; timestamp: number; no?: number }> {
|
|
100
|
+
// Defense-in-depth against path traversal — `date` builds a `${date}.json`
|
|
101
|
+
// path. The archive route also validates, but this is exported/reused.
|
|
102
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return []
|
|
103
|
+
let day: { chats?: Array<{ id?: number; exchanges?: ExchangeLike[] }> }
|
|
104
|
+
try {
|
|
105
|
+
day = JSON.parse(readFileSync(resolve(dir, `${date}.json`), 'utf8'))
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
const chat = (Array.isArray(day?.chats) ? day.chats : []).find((c) => c?.id === chatIndex)
|
|
110
|
+
if (!chat) return []
|
|
111
|
+
const exchanges: ExchangeLike[] = Array.isArray(chat.exchanges) ? chat.exchanges : []
|
|
112
|
+
const out: Array<{ query: string; text: string; timestamp: number; no?: number }> = []
|
|
113
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
114
|
+
const ex = exchanges[i]
|
|
115
|
+
if (ex?.role !== 'user') continue
|
|
116
|
+
const next = exchanges[i + 1]
|
|
117
|
+
if (next?.role !== 'assistant') continue
|
|
118
|
+
out.push({
|
|
119
|
+
query: ex.content ?? '',
|
|
120
|
+
text: next.content ?? '',
|
|
121
|
+
timestamp: next.timestamp ?? ex.timestamp ?? 0,
|
|
122
|
+
no: ex.globalMsgNum ?? next.globalMsgNum,
|
|
123
|
+
})
|
|
124
|
+
i++
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** ARCHIVE_DIR-bound form for the route. */
|
|
130
|
+
export function getArchiveChatMessagesNumbered(date: string, chatIndex: number) {
|
|
131
|
+
return readArchiveChatNumbered(ARCHIVE_DIR, date, chatIndex)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Highest stamped number across the day archives (0 when none). */
|
|
135
|
+
export function maxGlobalMsgNumInDir(dir: string): number {
|
|
136
|
+
let max = 0
|
|
137
|
+
let files: string[] = []
|
|
138
|
+
try {
|
|
139
|
+
files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
|
|
140
|
+
} catch {
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
for (const f of files) {
|
|
144
|
+
try {
|
|
145
|
+
const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
|
|
146
|
+
for (const chat of Array.isArray(day?.chats) ? day.chats : []) {
|
|
147
|
+
for (const ex of Array.isArray(chat?.exchanges) ? chat.exchanges : []) {
|
|
148
|
+
if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > max) max = ex.globalMsgNum
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch { /* skip */ }
|
|
152
|
+
}
|
|
153
|
+
return max
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveFromLiveSessions(num: number): ResolvedGlobalMessage | null {
|
|
157
|
+
const today = localDay() // local calendar day, not UTC — a live ref in the user's evening must not label tomorrow
|
|
158
|
+
for (const session of getActiveSessions()) {
|
|
159
|
+
const exchanges = (session as { exchanges?: ExchangeLike[] }).exchanges ?? []
|
|
160
|
+
const hit = scanExchanges(exchanges, num, today)
|
|
161
|
+
if (hit) return hit
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const messageRefRouter = Router()
|
|
167
|
+
|
|
168
|
+
messageRefRouter.get('/message/:num', (req, res) => {
|
|
169
|
+
const num = Number.parseInt(req.params.num, 10)
|
|
170
|
+
if (!Number.isFinite(num) || num < 1) {
|
|
171
|
+
res.status(400).json({ error: 'invalid message number' })
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
const hit = resolveFromLiveSessions(num) ?? resolveFromArchiveDir(ARCHIVE_DIR, num)
|
|
175
|
+
if (!hit) {
|
|
176
|
+
res.status(404).json({ error: `message ${num} not found` })
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
res.json(hit)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
messageRefRouter.get('/message-counter', (_req, res) => {
|
|
183
|
+
let liveMax = 0
|
|
184
|
+
for (const session of getActiveSessions()) {
|
|
185
|
+
for (const ex of ((session as { exchanges?: ExchangeLike[] }).exchanges ?? [])) {
|
|
186
|
+
if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR)) })
|
|
190
|
+
})
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Router } from 'express'
|
|
7
7
|
import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
|
|
8
8
|
import { callModelStreaming } from '../lib/model-router.js'
|
|
9
|
-
import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
9
|
+
import { normalizeModelPreference, DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
|
|
10
10
|
import { tryInstantResponse } from '../lib/response-cache.js'
|
|
11
11
|
import crypto from 'node:crypto'
|
|
12
12
|
|
|
@@ -79,7 +79,7 @@ function resolveModel(model?: string, _query?: string): ModelPreference {
|
|
|
79
79
|
if (model === 'cos-sonnet') return 'sonnet'
|
|
80
80
|
if (model === 'cos-haiku') return 'haiku'
|
|
81
81
|
if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-high'
|
|
82
|
-
return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ??
|
|
82
|
+
return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const MODEL_NAMES: Record<ModelPreference, string> = {
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Session endpoints — recent list, full history, existence check, client-format messages, context breaks, end
|
|
2
|
+
import { Router } from 'express'
|
|
3
|
+
import { readFileSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions } from '../lib/conversation.js'
|
|
6
|
+
import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
|
|
7
|
+
import { getArchiveDayMessages } from '../lib/archive.js'
|
|
8
|
+
import { localDay } from '../lib/local-day.js'
|
|
9
|
+
import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
|
|
10
|
+
|
|
11
|
+
export const sessionsRouter = Router()
|
|
12
|
+
|
|
13
|
+
sessionsRouter.get('/sessions/recent', (_req, res) => {
|
|
14
|
+
const sessions = getRecentSessions(24 * 60 * 60_000)
|
|
15
|
+
res.json({ sessions })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// HEAD /api/sessions/:id — lightweight existence check for restore validation
|
|
19
|
+
sessionsRouter.head('/sessions/:id', (req, res) => {
|
|
20
|
+
res.status(sessionExists(req.params.id) ? 200 : 404).end()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// GET /api/sessions/:id/history — full exchange list for session resume
|
|
24
|
+
sessionsRouter.get('/sessions/:id/history', (req, res) => {
|
|
25
|
+
const exchanges = getHistory(req.params.id)
|
|
26
|
+
if (exchanges.length === 0) {
|
|
27
|
+
res.status(404).json({ error: 'Session not found or empty' })
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
res.json({ exchanges })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// POST /api/sessions/:id/context-break — insert a context break (prompt history gate)
|
|
34
|
+
sessionsRouter.post('/sessions/:id/context-break', (req, res) => {
|
|
35
|
+
const ok = addContextBreak(req.params.id)
|
|
36
|
+
if (!ok) {
|
|
37
|
+
res.status(404).json({ error: 'Session not found' })
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
res.json({ ok: true })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// GET /api/sessions/:id/messages — client-compatible format (paired Q&A)
|
|
44
|
+
sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
45
|
+
const exchanges = getHistory(req.params.id)
|
|
46
|
+
if (exchanges.length === 0) {
|
|
47
|
+
res.status(404).json({ error: 'Session not found or empty' })
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pair user+assistant exchanges into client message format
|
|
52
|
+
const messages: Array<{ query: string; text: string; timestamp: number }> = []
|
|
53
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
54
|
+
const ex = exchanges[i]
|
|
55
|
+
if (ex.role === 'user') {
|
|
56
|
+
const next = exchanges[i + 1]
|
|
57
|
+
if (next && next.role === 'assistant') {
|
|
58
|
+
messages.push({ query: ex.content, text: next.content, timestamp: next.timestamp })
|
|
59
|
+
i++ // skip the assistant exchange
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
res.json({ messages })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// POST /api/sessions/:id/end — Explicitly end a session (archive + log + notify)
|
|
68
|
+
// Called by client on "new session", "clear session", or app backgrounding.
|
|
69
|
+
// Prevents data loss — session gets logged to .glasses_sessions.jsonl immediately
|
|
70
|
+
// instead of waiting for the 2hr TTL expiry.
|
|
71
|
+
// POST /api/sessions/lookup — batch resolve timestamps to session IDs
|
|
72
|
+
// Used to retroactively stamp messages that predate the sessionId feature
|
|
73
|
+
sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
74
|
+
const { timestamps } = req.body as { timestamps: number[] }
|
|
75
|
+
if (!timestamps || !Array.isArray(timestamps)) {
|
|
76
|
+
res.status(400).json({ error: 'timestamps[] required' })
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build time ranges from BOTH JSONL history AND live server sessions
|
|
81
|
+
const logPath = join(process.env.COS_SCRIPTS_DIR || '', '.glasses_sessions.jsonl')
|
|
82
|
+
const sessionRanges: Array<{ sid: string; start: number; end: number }> = []
|
|
83
|
+
|
|
84
|
+
// 1. Live server sessions (always current — no snapshot lag)
|
|
85
|
+
const recentSessions = getRecentSessions(24 * 60 * 60_000)
|
|
86
|
+
for (const rs of recentSessions) {
|
|
87
|
+
const raw = getSessionRaw(rs.id)
|
|
88
|
+
if (raw) {
|
|
89
|
+
sessionRanges.push({ sid: raw.id, start: raw.createdAt, end: Date.now() }) // extends to NOW
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. JSONL history (ended sessions + snapshots)
|
|
94
|
+
try {
|
|
95
|
+
const lines = readFileSync(logPath, 'utf-8').trim().split('\n')
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const d = JSON.parse(line)
|
|
98
|
+
if (d.session_id && d.created_at) {
|
|
99
|
+
const start = new Date(d.created_at).getTime()
|
|
100
|
+
const end = d.ended_at ? new Date(d.ended_at).getTime() : start + 7200_000
|
|
101
|
+
// Live sessions take priority. For JSONL, keep the widest (latest) time range per session.
|
|
102
|
+
const existing = sessionRanges.find(s => s.sid === d.session_id)
|
|
103
|
+
if (!existing) {
|
|
104
|
+
sessionRanges.push({ sid: d.session_id, start, end })
|
|
105
|
+
} else if (end > existing.end && !getSessionRaw(d.session_id)) {
|
|
106
|
+
// Widen the JSONL range (but don't overwrite live sessions which extend to NOW)
|
|
107
|
+
existing.end = end
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch { /* no log file */ }
|
|
112
|
+
|
|
113
|
+
// Match each timestamp to a session (live sessions checked first, then JSONL)
|
|
114
|
+
const results: Record<number, string | null> = {}
|
|
115
|
+
for (const ts of timestamps) {
|
|
116
|
+
let match: string | null = null
|
|
117
|
+
for (const s of sessionRanges) {
|
|
118
|
+
if (ts >= s.start && ts <= s.end) {
|
|
119
|
+
match = s.sid
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
results[ts] = match
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
res.json({ results, sessionsScanned: sessionRanges.length })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
sessionsRouter.post('/sessions/:id/end', async (req, res) => {
|
|
130
|
+
try {
|
|
131
|
+
const result = await endSession(req.params.id)
|
|
132
|
+
if (!result) {
|
|
133
|
+
res.status(404).json({ error: 'Session not found' })
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
// When logged === false the archive write failed — we keep the session
|
|
137
|
+
// in the Map for the next mirror to retry, but signal 503 so the client
|
|
138
|
+
// knows NOT to wipe local messages (they're still the user's only copy).
|
|
139
|
+
if (!result.logged && result.exchangeCount > 0) {
|
|
140
|
+
res.status(503).json({
|
|
141
|
+
ok: false,
|
|
142
|
+
error: 'Archive write failed — session retained for retry',
|
|
143
|
+
exchange_count: result.exchangeCount,
|
|
144
|
+
duration_minutes: result.durationMin,
|
|
145
|
+
})
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
clearCodexEngineSession(req.params.id)
|
|
149
|
+
res.json({
|
|
150
|
+
ok: true,
|
|
151
|
+
logged: result.logged,
|
|
152
|
+
exchange_count: result.exchangeCount,
|
|
153
|
+
duration_minutes: result.durationMin,
|
|
154
|
+
})
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('[sessions] /end unexpected error:', err)
|
|
157
|
+
res.status(500).json({ error: String(err) })
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// POST /api/sessions/:id/snapshot — write live session to .glasses_sessions.jsonl WITHOUT ending it
|
|
162
|
+
// Enables M3 Ultra TUI to read current glasses conversation while session is still active
|
|
163
|
+
sessionsRouter.post('/sessions/:id/snapshot', (req, res) => {
|
|
164
|
+
const session = getSessionRaw(req.params.id)
|
|
165
|
+
if (!session) {
|
|
166
|
+
res.status(404).json({ error: 'Session not found' })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const entry = buildSessionLogEntry({
|
|
171
|
+
id: session.id,
|
|
172
|
+
exchanges: session.exchanges,
|
|
173
|
+
createdAt: session.createdAt,
|
|
174
|
+
lastActivity: session.lastActivity,
|
|
175
|
+
modelPreference: session.modelPreference,
|
|
176
|
+
endReason: 'explicit_end', // marker — will be overwritten when session actually ends
|
|
177
|
+
slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const logged = writeSessionLog(entry)
|
|
181
|
+
res.json({
|
|
182
|
+
ok: true,
|
|
183
|
+
logged,
|
|
184
|
+
session_id: session.id,
|
|
185
|
+
message_count: entry.total_message_count,
|
|
186
|
+
messages_logged: entry.messages.length,
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// GET /api/sessions/today/live-chats — live session chat summaries for today (not yet archived).
|
|
191
|
+
// Date compare is LOCAL time so users chatting in CDT/PST late evening still see their
|
|
192
|
+
// session under "today" instead of "tomorrow UTC".
|
|
193
|
+
// Each summary includes `sessionId` so the client can drill down via
|
|
194
|
+
// `/api/sessions/:id/messages` — index=-1 is a sentinel and is NOT a valid archive chat index.
|
|
195
|
+
sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
|
|
196
|
+
const todayDate = localDay()
|
|
197
|
+
const liveSessions = getActiveSessions()
|
|
198
|
+
const chats: Array<{ index: number; summary: string; exchangeCount: number; startedAt: number; isLive: boolean; sessionId: string }> = []
|
|
199
|
+
|
|
200
|
+
for (const session of liveSessions) {
|
|
201
|
+
const sessionDay = localDay(session.lastActivity)
|
|
202
|
+
if (sessionDay !== todayDate) continue
|
|
203
|
+
if (session.exchanges.length === 0) continue
|
|
204
|
+
|
|
205
|
+
const firstQuery = session.exchanges.find(e => e.role === 'user')?.content ?? ''
|
|
206
|
+
const summary = firstQuery.length > 57 ? firstQuery.slice(0, 54) + '...' : firstQuery || 'Live session'
|
|
207
|
+
|
|
208
|
+
chats.push({
|
|
209
|
+
index: -1,
|
|
210
|
+
summary: `[LIVE] ${summary}`,
|
|
211
|
+
exchangeCount: session.exchanges.length,
|
|
212
|
+
startedAt: session.createdAt,
|
|
213
|
+
isLive: true,
|
|
214
|
+
sessionId: session.id,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
res.json({ chats })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// GET /api/sessions/today/all-messages — merged view of today's archived + live session messages.
|
|
222
|
+
// Dedup key is `sessionId|timestamp` (was bare timestamp, which collided on NTP skew or
|
|
223
|
+
// same-ms adds). `sessionId` is always known for live exchanges; archive messages fall
|
|
224
|
+
// back to the archived chat's sessionId via getArchiveDayMessages.
|
|
225
|
+
sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
226
|
+
const todayDate = localDay()
|
|
227
|
+
|
|
228
|
+
const archivedMessages = getArchiveDayMessages(todayDate).map(m => ({
|
|
229
|
+
...m,
|
|
230
|
+
source: 'archive' as const,
|
|
231
|
+
}))
|
|
232
|
+
|
|
233
|
+
const liveMessages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; source: 'live' }> = []
|
|
234
|
+
const liveSessions = getActiveSessions()
|
|
235
|
+
for (const session of liveSessions) {
|
|
236
|
+
const sessionDay = localDay(session.lastActivity)
|
|
237
|
+
if (sessionDay !== todayDate) continue
|
|
238
|
+
for (let i = 0; i < session.exchanges.length; i++) {
|
|
239
|
+
const ex = session.exchanges[i]
|
|
240
|
+
if (ex.role === 'user') {
|
|
241
|
+
const next = session.exchanges[i + 1]
|
|
242
|
+
if (next && next.role === 'assistant') {
|
|
243
|
+
liveMessages.push({
|
|
244
|
+
query: ex.content,
|
|
245
|
+
text: next.content,
|
|
246
|
+
timestamp: next.timestamp,
|
|
247
|
+
chatIndex: -1,
|
|
248
|
+
sessionId: session.id,
|
|
249
|
+
source: 'live',
|
|
250
|
+
})
|
|
251
|
+
i++
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Merge, dedup by (sessionId, timestamp), sort chronologically.
|
|
258
|
+
const seen = new Set<string>()
|
|
259
|
+
const keyOf = (m: { sessionId?: string; timestamp: number }) => `${m.sessionId ?? ''}|${m.timestamp}`
|
|
260
|
+
const merged = [...archivedMessages, ...liveMessages]
|
|
261
|
+
.filter(m => {
|
|
262
|
+
const k = keyOf(m as any)
|
|
263
|
+
if (seen.has(k)) return false
|
|
264
|
+
seen.add(k)
|
|
265
|
+
return true
|
|
266
|
+
})
|
|
267
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
268
|
+
|
|
269
|
+
res.json({ messages: merged, date: todayDate })
|
|
270
|
+
})
|
|
@@ -1170,11 +1170,16 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
|
|
|
1170
1170
|
const chunks = getSessionChunks(sessionId)
|
|
1171
1171
|
if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
|
|
1172
1172
|
await drainSessionAudioWrites(sessionId)
|
|
1173
|
-
|
|
1173
|
+
// Gap-aware assembly: chunks lost in transit surface as inline
|
|
1174
|
+
// "[… audio gap …]" markers instead of being silently stitched over.
|
|
1175
|
+
const transcript = getSessionTranscript(sessionId, { withGaps: true }) ?? ''
|
|
1176
|
+
const transferIntegrity = analyzeTranscriptGaps(sessionId)
|
|
1174
1177
|
res.json({
|
|
1175
1178
|
sessionId,
|
|
1176
1179
|
chunks: chunks.length,
|
|
1177
1180
|
transcriptChars: transcript.length,
|
|
1181
|
+
transcript,
|
|
1182
|
+
transferIntegrity,
|
|
1178
1183
|
readyToSave: true,
|
|
1179
1184
|
})
|
|
1180
1185
|
} catch (err: unknown) {
|
|
@@ -2,7 +2,7 @@ export type ClaudeModelPreference = 'opus' | 'sonnet' | 'haiku'
|
|
|
2
2
|
export type CodexModelPreference = 'codex-high'
|
|
3
3
|
export type ModelPreference = ClaudeModelPreference | CodexModelPreference
|
|
4
4
|
|
|
5
|
-
export const DEFAULT_MODEL = '
|
|
5
|
+
export const DEFAULT_MODEL = 'sonnet' as const
|
|
6
6
|
export const CODEX_HIGH_MODEL: CodexModelPreference = 'codex-high'
|
|
7
7
|
|
|
8
8
|
// Optional codex model passed to `codex exec --model`. Empty (the default) means
|