@gotcos/glasses-server 6.1.0
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/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- package/shared/model-preference.ts +81 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Writes .session_index_cache_COS-Glasses.json to COS_SCRIPTS_DIR
|
|
2
|
+
// so the TUI Sessions view picks up glasses sessions alongside desktop ones.
|
|
3
|
+
// Transforms active sessions + archived chats into SessionHistoryEntry format.
|
|
4
|
+
|
|
5
|
+
import { writeFileSync, renameSync, readdirSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { resolve, dirname } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import type { DailyArchive } from './archive.js'
|
|
9
|
+
|
|
10
|
+
// Lazy import to break circular dependency:
|
|
11
|
+
// conversation.ts imports us → we import conversation.ts → sessions Map not yet initialized
|
|
12
|
+
let _getActiveSessions: (() => any[]) | null = null
|
|
13
|
+
function getActiveSessions(): any[] {
|
|
14
|
+
if (!_getActiveSessions) {
|
|
15
|
+
// Dynamic import would be async; instead, caller injects via setSessionProvider()
|
|
16
|
+
return []
|
|
17
|
+
}
|
|
18
|
+
return _getActiveSessions()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Called by conversation.ts after sessions Map is initialized */
|
|
22
|
+
export function setSessionProvider(fn: () => any[]): void {
|
|
23
|
+
_getActiveSessions = fn
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Import COS_SCRIPTS_DIR independently — don't crash server if missing
|
|
27
|
+
let SCRIPTS_DIR: string | null = null
|
|
28
|
+
try {
|
|
29
|
+
if (process.env.COS_SCRIPTS_DIR) {
|
|
30
|
+
SCRIPTS_DIR = resolve(process.env.COS_SCRIPTS_DIR)
|
|
31
|
+
}
|
|
32
|
+
} catch { /* no-op */ }
|
|
33
|
+
|
|
34
|
+
const DEVICE_ID = 'COS-Glasses'
|
|
35
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
36
|
+
import { dataPath } from './data-dir.js'
|
|
37
|
+
const ARCHIVE_DIR = dataPath('archive')
|
|
38
|
+
const MAX_ARCHIVE_AGE_DAYS = 30
|
|
39
|
+
|
|
40
|
+
interface SessionCacheEntry {
|
|
41
|
+
session_id: string
|
|
42
|
+
glasses_session_id: string // Original UUID (e.g. "dea05c6e") — preserved for COS lookups
|
|
43
|
+
slug: string
|
|
44
|
+
created: string
|
|
45
|
+
modified: string
|
|
46
|
+
duration_minutes: number
|
|
47
|
+
user_message_count: number
|
|
48
|
+
assistant_message_count: number
|
|
49
|
+
message_count: number
|
|
50
|
+
first_prompt: string
|
|
51
|
+
tools_used: Record<string, number>
|
|
52
|
+
files_touched: string[]
|
|
53
|
+
domain: string
|
|
54
|
+
git_branch: string
|
|
55
|
+
has_subagents: boolean
|
|
56
|
+
total_input_tokens: number
|
|
57
|
+
total_output_tokens: number
|
|
58
|
+
file_size_bytes: number
|
|
59
|
+
device_id: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Domain classification from user message text — lightweight keyword vote.
|
|
63
|
+
// Customize the keyword lists for your own workspaces.
|
|
64
|
+
const DOMAIN_TEXT_RULES: [string[], string][] = [
|
|
65
|
+
[['personal', 'family', 'home', 'health', 'glasses', 'oura', 'even g2'], 'personal'],
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
function classifyDomain(userMessages: string[]): string {
|
|
69
|
+
const votes: Record<string, number> = {}
|
|
70
|
+
const text = userMessages.join(' ').toLowerCase()
|
|
71
|
+
|
|
72
|
+
for (const [keywords, domain] of DOMAIN_TEXT_RULES) {
|
|
73
|
+
for (const kw of keywords) {
|
|
74
|
+
if (text.includes(kw)) {
|
|
75
|
+
votes[domain] = (votes[domain] || 0) + 1
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let best = ''
|
|
81
|
+
let bestCount = 0
|
|
82
|
+
for (const [domain, count] of Object.entries(votes)) {
|
|
83
|
+
if (count > bestCount) {
|
|
84
|
+
best = domain
|
|
85
|
+
bestCount = count
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return best || 'personal'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildEntryFromArchiveChat(
|
|
93
|
+
chat: DailyArchive['chats'][0],
|
|
94
|
+
date: string,
|
|
95
|
+
chatIndex: number,
|
|
96
|
+
): SessionCacheEntry {
|
|
97
|
+
const userExchanges = chat.exchanges.filter(e => e.role === 'user')
|
|
98
|
+
const assistantExchanges = chat.exchanges.filter(e => e.role === 'assistant')
|
|
99
|
+
const userMessages = userExchanges.map(e => e.content)
|
|
100
|
+
const firstUserContent = userMessages[0] ?? ''
|
|
101
|
+
|
|
102
|
+
// Preserve original session UUID if archived (added in v3.9.0), else fallback to index-based ID
|
|
103
|
+
const originalId = (chat as any).sessionId || ''
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
session_id: `glasses-${date}-${chatIndex}`,
|
|
107
|
+
glasses_session_id: originalId,
|
|
108
|
+
slug: chat.summary || firstUserContent.slice(0, 60) || 'Glasses chat',
|
|
109
|
+
created: new Date(chat.startedAt).toISOString(),
|
|
110
|
+
modified: new Date(chat.endedAt).toISOString(),
|
|
111
|
+
duration_minutes: Math.round((chat.endedAt - chat.startedAt) / 60_000),
|
|
112
|
+
user_message_count: userExchanges.length,
|
|
113
|
+
assistant_message_count: assistantExchanges.length,
|
|
114
|
+
message_count: chat.exchangeCount,
|
|
115
|
+
first_prompt: firstUserContent.slice(0, 200),
|
|
116
|
+
tools_used: {},
|
|
117
|
+
files_touched: [],
|
|
118
|
+
domain: classifyDomain(userMessages),
|
|
119
|
+
git_branch: 'n/a',
|
|
120
|
+
has_subagents: false,
|
|
121
|
+
total_input_tokens: 0,
|
|
122
|
+
total_output_tokens: 0,
|
|
123
|
+
file_size_bytes: 0,
|
|
124
|
+
device_id: DEVICE_ID,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildEntryFromActiveSession(session: {
|
|
129
|
+
id: string
|
|
130
|
+
exchanges: Array<{ role: string; content: string; timestamp: number }>
|
|
131
|
+
createdAt: number
|
|
132
|
+
lastActivity: number
|
|
133
|
+
}): SessionCacheEntry {
|
|
134
|
+
const userExchanges = session.exchanges.filter(e => e.role === 'user')
|
|
135
|
+
const assistantExchanges = session.exchanges.filter(e => e.role === 'assistant')
|
|
136
|
+
const userMessages = userExchanges.map(e => e.content)
|
|
137
|
+
const firstUserContent = userMessages[0] ?? ''
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
session_id: `glasses-${session.id}`,
|
|
141
|
+
glasses_session_id: session.id,
|
|
142
|
+
slug: firstUserContent.slice(0, 60) || 'Active glasses session',
|
|
143
|
+
created: new Date(session.createdAt).toISOString(),
|
|
144
|
+
modified: new Date(session.lastActivity).toISOString(),
|
|
145
|
+
duration_minutes: Math.round((session.lastActivity - session.createdAt) / 60_000),
|
|
146
|
+
user_message_count: userExchanges.length,
|
|
147
|
+
assistant_message_count: assistantExchanges.length,
|
|
148
|
+
message_count: session.exchanges.length,
|
|
149
|
+
first_prompt: firstUserContent.slice(0, 200),
|
|
150
|
+
tools_used: {},
|
|
151
|
+
files_touched: [],
|
|
152
|
+
domain: classifyDomain(userMessages),
|
|
153
|
+
git_branch: 'n/a',
|
|
154
|
+
has_subagents: false,
|
|
155
|
+
total_input_tokens: 0,
|
|
156
|
+
total_output_tokens: 0,
|
|
157
|
+
file_size_bytes: 0,
|
|
158
|
+
device_id: DEVICE_ID,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function loadArchivedSessions(): SessionCacheEntry[] {
|
|
163
|
+
const entries: SessionCacheEntry[] = []
|
|
164
|
+
const cutoff = Date.now() - MAX_ARCHIVE_AGE_DAYS * 86_400_000
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const files = readdirSync(ARCHIVE_DIR).filter(f => f.endsWith('.json')).sort()
|
|
168
|
+
for (const fname of files) {
|
|
169
|
+
const date = fname.replace('.json', '')
|
|
170
|
+
// Skip archives older than 30 days
|
|
171
|
+
const fileDate = new Date(date + 'T00:00:00Z').getTime()
|
|
172
|
+
if (fileDate < cutoff) continue
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const raw = readFileSync(resolve(ARCHIVE_DIR, fname), 'utf-8')
|
|
176
|
+
const archive: DailyArchive = JSON.parse(raw)
|
|
177
|
+
for (let i = 0; i < archive.chats.length; i++) {
|
|
178
|
+
entries.push(buildEntryFromArchiveChat(archive.chats[i], date, i))
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Skip corrupt archive files
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// Archive dir doesn't exist yet — fine
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return entries
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function updateGlassesSessionCache(): void {
|
|
192
|
+
if (!SCRIPTS_DIR) {
|
|
193
|
+
console.warn('[session-cache] COS_SCRIPTS_DIR not set — skipping cache write')
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const sessions: Record<string, SessionCacheEntry> = {}
|
|
198
|
+
|
|
199
|
+
// 1. Archived sessions
|
|
200
|
+
const archived = loadArchivedSessions()
|
|
201
|
+
for (const entry of archived) {
|
|
202
|
+
sessions[entry.session_id] = entry
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Active sessions — always included (mutually exclusive with archives;
|
|
206
|
+
// conversation.ts deletes sessions from memory after archiving)
|
|
207
|
+
const active = getActiveSessions()
|
|
208
|
+
for (const session of active) {
|
|
209
|
+
const entry = buildEntryFromActiveSession(session)
|
|
210
|
+
sessions[entry.session_id] = entry
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3. Atomic write: .tmp then rename
|
|
214
|
+
const cachePath = resolve(SCRIPTS_DIR, '.session_index_cache_COS-Glasses.json')
|
|
215
|
+
const tmpPath = cachePath + '.tmp'
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const cache = {
|
|
219
|
+
sessions,
|
|
220
|
+
device_id: DEVICE_ID,
|
|
221
|
+
updated_at: new Date().toISOString(),
|
|
222
|
+
session_count: Object.keys(sessions).length,
|
|
223
|
+
}
|
|
224
|
+
writeFileSync(tmpPath, JSON.stringify(cache, null, 2))
|
|
225
|
+
renameSync(tmpPath, cachePath)
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error('[session-cache] Failed to write cache:', err)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Debounced update ─────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
let cacheTimer: ReturnType<typeof setTimeout> | null = null
|
|
234
|
+
|
|
235
|
+
export function scheduleCacheUpdate(): void {
|
|
236
|
+
if (cacheTimer) return
|
|
237
|
+
cacheTimer = setTimeout(() => {
|
|
238
|
+
cacheTimer = null
|
|
239
|
+
updateGlassesSessionCache()
|
|
240
|
+
}, 5_000) // 5s debounce
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Flush any pending debounced cache write immediately */
|
|
244
|
+
export function flushCacheWrite(): void {
|
|
245
|
+
if (cacheTimer) {
|
|
246
|
+
clearTimeout(cacheTimer)
|
|
247
|
+
cacheTimer = null
|
|
248
|
+
updateGlassesSessionCache()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Init (called from index.ts after all modules are loaded) ─
|
|
253
|
+
export function initSessionCache(): void {
|
|
254
|
+
updateGlassesSessionCache()
|
|
255
|
+
console.log('[session-cache] Initial cache written')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── SIGTERM/SIGINT safety ────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
process.on('SIGTERM', () => {
|
|
261
|
+
flushCacheWrite()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
process.on('SIGINT', () => {
|
|
265
|
+
flushCacheWrite()
|
|
266
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Session end logger — writes .glasses_sessions.jsonl to COS_SCRIPTS_DIR
|
|
2
|
+
// so COS can query Glasses sessions by original UUID, date, domain, or content.
|
|
3
|
+
// Fires on: TTL expiry, explicit /api/sessions/:id/end, server shutdown.
|
|
4
|
+
|
|
5
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
6
|
+
import { resolve, dirname } from 'node:path'
|
|
7
|
+
import type { Exchange } from './conversation.js'
|
|
8
|
+
|
|
9
|
+
// Resolve COS_SCRIPTS_DIR independently (same pattern as session-cache-writer)
|
|
10
|
+
let SCRIPTS_DIR: string | null = null
|
|
11
|
+
try {
|
|
12
|
+
if (process.env.COS_SCRIPTS_DIR) {
|
|
13
|
+
SCRIPTS_DIR = resolve(process.env.COS_SCRIPTS_DIR)
|
|
14
|
+
}
|
|
15
|
+
} catch { /* no-op */ }
|
|
16
|
+
|
|
17
|
+
const LOG_FILE_NAME = '.glasses_sessions.jsonl'
|
|
18
|
+
|
|
19
|
+
// Domain classification — lightweight keyword vote, identical to session-cache-writer.ts.
|
|
20
|
+
// Customize the keyword lists for your own workspaces; defaults to a work/personal split.
|
|
21
|
+
const DOMAIN_TEXT_RULES: [string[], string][] = [
|
|
22
|
+
[['personal', 'family', 'home', 'health', 'glasses', 'oura', 'even g2'], 'personal'],
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
function classifyDomain(userMessages: string[]): string {
|
|
26
|
+
const votes: Record<string, number> = {}
|
|
27
|
+
const text = userMessages.join(' ').toLowerCase()
|
|
28
|
+
|
|
29
|
+
for (const [keywords, domain] of DOMAIN_TEXT_RULES) {
|
|
30
|
+
for (const kw of keywords) {
|
|
31
|
+
if (text.includes(kw)) {
|
|
32
|
+
votes[domain] = (votes[domain] || 0) + 1
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let best = ''
|
|
38
|
+
let bestCount = 0
|
|
39
|
+
for (const [domain, count] of Object.entries(votes)) {
|
|
40
|
+
if (count > bestCount) {
|
|
41
|
+
best = domain
|
|
42
|
+
bestCount = count
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return best || 'personal'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SessionLogEntry {
|
|
50
|
+
// Identity
|
|
51
|
+
session_id: string // Original 8-char UUID (e.g. "dea05c6e")
|
|
52
|
+
device_id: string // "COS-Glasses"
|
|
53
|
+
|
|
54
|
+
// Timing
|
|
55
|
+
created_at: string // ISO
|
|
56
|
+
ended_at: string // ISO
|
|
57
|
+
duration_minutes: number
|
|
58
|
+
end_reason: 'ttl_expiry' | 'explicit_end' | 'server_shutdown' | 'explicit_clear'
|
|
59
|
+
|
|
60
|
+
// Classification
|
|
61
|
+
domain: string // general | personal (customizable)
|
|
62
|
+
slug: string // <60 char summary (first user query until archive generates LLM title)
|
|
63
|
+
|
|
64
|
+
// Model
|
|
65
|
+
model_preference: string | null
|
|
66
|
+
|
|
67
|
+
// Counts
|
|
68
|
+
user_message_count: number
|
|
69
|
+
assistant_message_count: number
|
|
70
|
+
total_message_count: number
|
|
71
|
+
|
|
72
|
+
// Content — full paired Q&A for COS queries
|
|
73
|
+
messages: Array<{
|
|
74
|
+
query: string
|
|
75
|
+
response: string
|
|
76
|
+
timestamp: string // ISO
|
|
77
|
+
}>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface EndSessionInput {
|
|
81
|
+
id: string
|
|
82
|
+
exchanges: Exchange[]
|
|
83
|
+
createdAt: number
|
|
84
|
+
lastActivity: number
|
|
85
|
+
modelPreference: string | null
|
|
86
|
+
endReason: 'ttl_expiry' | 'explicit_end' | 'server_shutdown' | 'explicit_clear'
|
|
87
|
+
slug?: string // LLM-generated summary if available
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build a log entry from session data */
|
|
91
|
+
export function buildSessionLogEntry(input: EndSessionInput): SessionLogEntry {
|
|
92
|
+
const userExchanges = input.exchanges.filter(e => e.role === 'user')
|
|
93
|
+
const assistantExchanges = input.exchanges.filter(e => e.role === 'assistant')
|
|
94
|
+
const userMessages = userExchanges.map(e => e.content)
|
|
95
|
+
|
|
96
|
+
// Pair user+assistant into messages
|
|
97
|
+
const messages: SessionLogEntry['messages'] = []
|
|
98
|
+
for (let i = 0; i < input.exchanges.length; i++) {
|
|
99
|
+
const ex = input.exchanges[i]
|
|
100
|
+
if (ex.role === 'user') {
|
|
101
|
+
const next = input.exchanges[i + 1]
|
|
102
|
+
if (next && next.role === 'assistant') {
|
|
103
|
+
messages.push({
|
|
104
|
+
query: ex.content,
|
|
105
|
+
response: next.content,
|
|
106
|
+
timestamp: new Date(next.timestamp).toISOString(),
|
|
107
|
+
})
|
|
108
|
+
i++ // skip paired assistant
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const firstUserContent = userMessages[0] ?? ''
|
|
114
|
+
const slug = input.slug || (firstUserContent.length > 57
|
|
115
|
+
? firstUserContent.slice(0, 57) + '...'
|
|
116
|
+
: firstUserContent || 'Empty session')
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
session_id: input.id,
|
|
120
|
+
device_id: 'COS-Glasses',
|
|
121
|
+
created_at: new Date(input.createdAt).toISOString(),
|
|
122
|
+
ended_at: new Date(input.lastActivity).toISOString(),
|
|
123
|
+
duration_minutes: Math.round((input.lastActivity - input.createdAt) / 60_000),
|
|
124
|
+
end_reason: input.endReason,
|
|
125
|
+
domain: classifyDomain(userMessages),
|
|
126
|
+
slug,
|
|
127
|
+
model_preference: input.modelPreference,
|
|
128
|
+
user_message_count: userExchanges.length,
|
|
129
|
+
assistant_message_count: assistantExchanges.length,
|
|
130
|
+
total_message_count: input.exchanges.length,
|
|
131
|
+
messages,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Append a session log entry to .glasses_sessions.jsonl */
|
|
136
|
+
export function writeSessionLog(entry: SessionLogEntry): boolean {
|
|
137
|
+
if (!SCRIPTS_DIR) {
|
|
138
|
+
console.warn('[session-log] COS_SCRIPTS_DIR not set — skipping session log')
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const logPath = resolve(SCRIPTS_DIR, LOG_FILE_NAME)
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
mkdirSync(dirname(logPath), { recursive: true })
|
|
146
|
+
appendFileSync(logPath, JSON.stringify(entry) + '\n')
|
|
147
|
+
console.log(`[session-log] Logged session ${entry.session_id} (${entry.end_reason}, ${entry.duration_minutes}m, ${entry.total_message_count} msgs)`)
|
|
148
|
+
return true
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error('[session-log] Failed to write session log:', err)
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** End a session: build entry and write to JSONL */
|
|
156
|
+
export function logSessionEnd(input: EndSessionInput): SessionLogEntry | null {
|
|
157
|
+
if (input.exchanges.length === 0) return null
|
|
158
|
+
|
|
159
|
+
const entry = buildSessionLogEntry(input)
|
|
160
|
+
writeSessionLog(entry)
|
|
161
|
+
return entry
|
|
162
|
+
}
|