@gotcos/glasses-server 6.45.2 → 6.45.3
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 +10 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/archive.ts +2 -1
- package/server/lib/conversation.ts +48 -4
- package/server/lib/recent-messages.ts +56 -0
- package/server/lib/workspace-skills.ts +209 -0
- package/server/routes/archive.ts +3 -0
- package/server/routes/sessions.ts +25 -22
- package/server/routes/skills.ts +13 -0
- package/server/routes/voice.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 6.45.3
|
|
2
|
+
|
|
3
|
+
The phone can list the skills this Mac will actually run.
|
|
4
|
+
|
|
5
|
+
- `GET /api/skills` returns the slash catalog from the selected workspace (`.agents/skills`, then `.claude/skills`) plus `~/.claude/skills`. Nested agent folders flatten with `:`. Generated `source-command-*` duplicates stay out. Paths never leave the box.
|
|
6
|
+
- COS Glasses 6.9.468 paints that list on Views instead of a hardcoded cheat sheet.
|
|
7
|
+
- A held voice can be heard before it is named. `GET /api/voice/ext-audio` lists `chunkIndices` per held session and `GET /api/voice/ext-audio/:sessionId/sample?chunk=<index>` serves that one chunk (no `chunk` still serves the newest). COS Control's Add-a-voice panel uses it to play portions of a session before naming it (Queen, 2026-09-12: "no way to listen to the voices that are here").
|
|
8
|
+
- Recent is a rolling window. `GET /api/sessions/today/all-messages` keeps its path for COS Control and the phone but now answers with the newest 30 messages (`?limit=` up to 100) across every live session and as many archived days as it takes, deduplicated against their archive mirrors. Messages leave Recent only by ageing out of the window; nothing is hidden by the calendar. Control's Recent view and the phone's history recovery read this without change.
|
|
9
|
+
- Yesterday's turns no longer vanish for a day. The archive mirror ran every 24 h from boot (21:25 on the current uptime), so finished sessions from the previous local day were neither "today" for `GET /api/sessions/today/all-messages` nor in any day archive until the evening; on 2026-09-12 07:00 Control showed no turns while ten from the 11th sat live in `sessions.json`. The mirror now runs at boot, at the next local midnight, and lazily from the today and archive-listing routes, once per local day. `checkYesterdayArchive` keys on the local day like everything else.
|
|
10
|
+
|
|
1
11
|
## 6.45.2
|
|
2
12
|
|
|
3
13
|
The G2 Sessions list answers in under a second again instead of eleven, and loading it no longer stalls the rest of the server.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.45.
|
|
3
|
+
"version": "6.45.3",
|
|
4
4
|
"description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { liveCuesRouter } from './routes/live-cues.js'
|
|
|
62
62
|
import { tasksRouter } from './routes/tasks.js'
|
|
63
63
|
import { memoryRouter } from './routes/memory.js'
|
|
64
64
|
import { threadsRouter } from './routes/threads.js'
|
|
65
|
+
import { skillsRouter } from './routes/skills.js'
|
|
65
66
|
import { shutdownLiveCues } from './lib/live-cues-engine.js'
|
|
66
67
|
import { prewarmContext } from './lib/context-builder.js'
|
|
67
68
|
import { preWarmCLI } from './lib/claude-bridge.js'
|
|
@@ -671,6 +672,7 @@ app.use('/api', meetingRouter)
|
|
|
671
672
|
app.use('/api', meetingsRouter)
|
|
672
673
|
app.use('/api', memoryRouter)
|
|
673
674
|
app.use('/api', threadsRouter)
|
|
675
|
+
app.use('/api', skillsRouter)
|
|
674
676
|
app.use('/api', openaiKeyRouter)
|
|
675
677
|
// v6.3.0 — Message History, cross-day 'reference message N', and history
|
|
676
678
|
// recovery for public npx users (previously full-COS-server only).
|
package/server/lib/archive.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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
|
|
|
6
|
+
import { localDay } from './local-day.js'
|
|
6
7
|
import { chmodSync, mkdirSync, readdirSync } from 'node:fs'
|
|
7
8
|
import { resolve, dirname } from 'node:path'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
@@ -549,7 +550,7 @@ export function getArchiveDayMessages(
|
|
|
549
550
|
|
|
550
551
|
/** Check if yesterday needs archiving (handles overnight server restarts) */
|
|
551
552
|
export function checkYesterdayArchive(): void {
|
|
552
|
-
const yesterday =
|
|
553
|
+
const yesterday = localDay(Date.now() - 86_400_000)
|
|
553
554
|
const existing = loadArchive(yesterday)
|
|
554
555
|
if (!existing) {
|
|
555
556
|
// No yesterday archive exists — but we can't archive sessions that are already expired
|
|
@@ -288,11 +288,55 @@ async function runDailyArchiveMirror(): Promise<void> {
|
|
|
288
288
|
if (mirrored > 0) updateGlassesSessionCache()
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// 6.45.3 — the mirror runs at every LOCAL DAY ROLLOVER, not only every 24 h from
|
|
292
|
+
// boot. With a server started at 21:24 the interval landed at 21:25 each evening,
|
|
293
|
+
// so a whole day's finished sessions sat in a blind spot until then: not "today"
|
|
294
|
+
// for `GET /api/sessions/today/all-messages`, not yet in any day archive. On
|
|
295
|
+
// 2026-09-12 07:00 Control showed "No turns today" (true) and no archive for
|
|
296
|
+
// 2026-09-11 (ten turns, #125 to #134, still live in sessions.json). Three
|
|
297
|
+
// triggers now: boot, a timer aimed at the next local midnight, and a lazy check
|
|
298
|
+
// from the routes that read the day views, so yesterday appears on the first
|
|
299
|
+
// read after midnight. Serialized: one run at a time, once per local day.
|
|
300
|
+
let lastMirrorDay = ''
|
|
301
|
+
let mirrorInFlight: Promise<void> | null = null
|
|
302
|
+
|
|
303
|
+
/** Milliseconds until thirty seconds past the next local midnight; never less than a second. */
|
|
304
|
+
export function msUntilNextLocalDay(now = Date.now()): number {
|
|
305
|
+
const d = new Date(now)
|
|
306
|
+
const next = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 30)
|
|
307
|
+
return Math.max(1_000, next.getTime() - now)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Mirror prior-day sessions once per local day. Returns true when a run happened. */
|
|
311
|
+
export async function ensureArchiveMirrorForDay(now = Date.now()): Promise<boolean> {
|
|
312
|
+
const today = localDay(now)
|
|
313
|
+
if (lastMirrorDay === today) return false
|
|
314
|
+
if (!mirrorInFlight) {
|
|
315
|
+
mirrorInFlight = runDailyArchiveMirror()
|
|
316
|
+
.then(() => { lastMirrorDay = today })
|
|
317
|
+
.finally(() => { mirrorInFlight = null })
|
|
318
|
+
}
|
|
319
|
+
await mirrorInFlight
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Test seam: forget the last mirrored day. */
|
|
324
|
+
export function __resetArchiveMirrorForTests(): void {
|
|
325
|
+
lastMirrorDay = ''
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scheduleArchiveMirrorAtNextLocalDay(): void {
|
|
329
|
+
const timer = setTimeout(() => {
|
|
330
|
+
ensureArchiveMirrorForDay()
|
|
331
|
+
.catch(err => console.error('[conversation] mirror rollover error:', err))
|
|
332
|
+
.finally(scheduleArchiveMirrorAtNextLocalDay)
|
|
333
|
+
}, msUntilNextLocalDay())
|
|
334
|
+
timer.unref?.()
|
|
335
|
+
}
|
|
336
|
+
|
|
291
337
|
// Fire-and-forget at boot so module load isn't blocked on disk + LLM fallback I/O.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
runDailyArchiveMirror().catch(err => console.error('[conversation] mirror interval error:', err))
|
|
295
|
-
}, 24 * 60 * 60_000)
|
|
338
|
+
ensureArchiveMirrorForDay().catch(err => console.error('[conversation] mirror boot error:', err))
|
|
339
|
+
scheduleArchiveMirrorAtNextLocalDay()
|
|
296
340
|
|
|
297
341
|
// Track whether session is brand new (for first-query notification)
|
|
298
342
|
const newSessions = new Set<string>()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 6.45.3 — "Recent" is a rolling window of the newest messages, not a calendar day.
|
|
2
|
+
//
|
|
3
|
+
// Miles, 2026-09-12: "The recent messages should just be a rolling count of 30,
|
|
4
|
+
// where, as messages age out of that top 30, they're no longer present in the
|
|
5
|
+
// recent tab. At any rate, we should never end up in a situation where messages
|
|
6
|
+
// are hidden." The old view was keyed on the local day, so at 07:00 the answer
|
|
7
|
+
// was empty while ten turns from the previous evening sat in the live store.
|
|
8
|
+
//
|
|
9
|
+
// The window is assembled from every live session (era-filtered) and then from
|
|
10
|
+
// archived days newest-first, only as many days as it takes to fill the window.
|
|
11
|
+
// Live copies and their archive mirrors are the same turn: dedup on
|
|
12
|
+
// `sessionId|timestamp`, the key the day view has used since the NTP-skew fix.
|
|
13
|
+
|
|
14
|
+
export interface RecentMessageCandidate {
|
|
15
|
+
sessionId?: string
|
|
16
|
+
timestamp: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RECENT_MESSAGES_DEFAULT_LIMIT = 30
|
|
20
|
+
export const RECENT_MESSAGES_MAX_LIMIT = 100
|
|
21
|
+
|
|
22
|
+
export function recentMessagesLimit(raw: unknown): number {
|
|
23
|
+
const n = typeof raw === 'string' ? Number.parseInt(raw, 10) : typeof raw === 'number' ? raw : NaN
|
|
24
|
+
if (!Number.isFinite(n) || n < 1) return RECENT_MESSAGES_DEFAULT_LIMIT
|
|
25
|
+
return Math.min(RECENT_MESSAGES_MAX_LIMIT, Math.floor(n))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The newest `limit` messages, chronological (oldest first, like the day view).
|
|
30
|
+
* `archiveDaysNewestFirst` is consulted lazily, one day at a time, and only
|
|
31
|
+
* while the window is not yet full.
|
|
32
|
+
*/
|
|
33
|
+
export function selectRecentMessages<T extends RecentMessageCandidate>(
|
|
34
|
+
live: T[],
|
|
35
|
+
archiveDaysNewestFirst: Iterable<() => T[]>,
|
|
36
|
+
limit: number,
|
|
37
|
+
): T[] {
|
|
38
|
+
const seen = new Set<string>()
|
|
39
|
+
const keyOf = (m: RecentMessageCandidate) => `${m.sessionId ?? ''}|${m.timestamp}`
|
|
40
|
+
const candidates: T[] = []
|
|
41
|
+
const take = (rows: T[]) => {
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
const key = keyOf(row)
|
|
44
|
+
if (seen.has(key)) continue
|
|
45
|
+
seen.add(key)
|
|
46
|
+
candidates.push(row)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
take(live)
|
|
50
|
+
for (const readDay of archiveDaysNewestFirst) {
|
|
51
|
+
if (candidates.length >= limit) break
|
|
52
|
+
take(readDay())
|
|
53
|
+
}
|
|
54
|
+
candidates.sort((a, b) => a.timestamp - b.timestamp)
|
|
55
|
+
return candidates.length > limit ? candidates.slice(candidates.length - limit) : candidates
|
|
56
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Catalog of skills the glasses agent can actually run: the selected COS
|
|
2
|
+
// workspace plus the user's global Claude skills. Labels only — never paths.
|
|
3
|
+
//
|
|
4
|
+
// Walk matches skill_sync.py: `.agents/skills` is canonical (nested folders
|
|
5
|
+
// flatten with `:`), `.claude/skills` is a one-level mirror, `source-command-*`
|
|
6
|
+
// is excluded as a generated duplicate. User skills live in `~/.claude/skills`.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { basename, join, relative } from 'node:path'
|
|
11
|
+
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
12
|
+
import { resolveProviderWorkDir } from './launch-dir.js'
|
|
13
|
+
|
|
14
|
+
export const SKILLS_CATALOG_SCHEMA = 1
|
|
15
|
+
export const SKILLS_CATALOG_MAX = 200
|
|
16
|
+
const SKILL_FILE_MAX_BYTES = 8_192
|
|
17
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
|
18
|
+
const EXCLUDE_PREFIXES = ['source-command-']
|
|
19
|
+
|
|
20
|
+
export interface WorkspaceSkill {
|
|
21
|
+
name: string
|
|
22
|
+
slash: string
|
|
23
|
+
description: string
|
|
24
|
+
group: string
|
|
25
|
+
where: 'workspace' | 'user'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkspaceSkillsCatalog {
|
|
29
|
+
schemaVersion: number
|
|
30
|
+
skills: WorkspaceSkill[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseSkillFrontmatter(text: string): Record<string, string> {
|
|
34
|
+
const src = text.replace(/^\uFEFF/, '')
|
|
35
|
+
if (!src.startsWith('---')) return {}
|
|
36
|
+
const end = src.indexOf('\n---', 3)
|
|
37
|
+
if (end < 0) return {}
|
|
38
|
+
const block = src.slice(3, end).replace(/^\r?\n/, '')
|
|
39
|
+
const out: Record<string, string> = {}
|
|
40
|
+
const lines = block.split(/\r?\n/)
|
|
41
|
+
let i = 0
|
|
42
|
+
while (i < lines.length) {
|
|
43
|
+
const line = lines[i]
|
|
44
|
+
const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
|
|
45
|
+
if (!match) { i += 1; continue }
|
|
46
|
+
const key = match[1]
|
|
47
|
+
let raw = match[2].trim()
|
|
48
|
+
if (raw === '>' || raw === '>-' || raw === '|' || raw === '|-') {
|
|
49
|
+
const parts: string[] = []
|
|
50
|
+
i += 1
|
|
51
|
+
while (i < lines.length && /^\s+\S/.test(lines[i]) && !/^[A-Za-z_][\w-]*:/.test(lines[i])) {
|
|
52
|
+
parts.push(lines[i].trim())
|
|
53
|
+
i += 1
|
|
54
|
+
}
|
|
55
|
+
out[key] = unquote(parts.join(' '))
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
out[key] = unquote(raw)
|
|
59
|
+
i += 1
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function unquote(value: string): string {
|
|
65
|
+
const trimmed = value.trim()
|
|
66
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
67
|
+
return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'").trim()
|
|
68
|
+
}
|
|
69
|
+
return trimmed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function excludedName(name: string): boolean {
|
|
73
|
+
return EXCLUDE_PREFIXES.some(prefix => name === prefix.slice(0, -1) || name.startsWith(prefix))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function skillNameFromRel(rel: string): string | null {
|
|
77
|
+
const parts = rel.split(/[/\\]/).filter(part => part && part !== '.')
|
|
78
|
+
if (parts.length === 0 || parts.some(part => part.startsWith('.') || excludedName(part))) return null
|
|
79
|
+
const name = parts.join(':')
|
|
80
|
+
return SKILL_NAME_RE.test(name) ? name : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readSkillFile(path: string): { name?: string; description: string } | null {
|
|
84
|
+
let stat
|
|
85
|
+
try { stat = statSync(path) } catch { return null }
|
|
86
|
+
if (!stat.isFile() || stat.size <= 0) return null
|
|
87
|
+
const bytes = Math.min(stat.size, SKILL_FILE_MAX_BYTES)
|
|
88
|
+
let text: string
|
|
89
|
+
try {
|
|
90
|
+
text = readFileSync(path, { encoding: 'utf8' }).slice(0, bytes)
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
const meta = parseSkillFrontmatter(text)
|
|
95
|
+
const description = (meta.description || '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
96
|
+
const name = meta.name && SKILL_NAME_RE.test(meta.name) && !excludedName(meta.name) ? meta.name : undefined
|
|
97
|
+
return { name, description }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkImmediateSkills(root: string): Array<{ rel: string; path: string }> {
|
|
101
|
+
let entries
|
|
102
|
+
try { entries = readdirSync(root, { withFileTypes: true }) } catch { return [] }
|
|
103
|
+
const found: Array<{ rel: string; path: string }> = []
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
106
|
+
const path = join(root, entry.name, 'SKILL.md')
|
|
107
|
+
if (existsSync(path)) found.push({ rel: entry.name, path })
|
|
108
|
+
}
|
|
109
|
+
return found
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function walkNestedSkills(root: string): Array<{ rel: string; path: string }> {
|
|
113
|
+
const found: Array<{ rel: string; path: string }> = []
|
|
114
|
+
const stack = [root]
|
|
115
|
+
while (stack.length) {
|
|
116
|
+
const dir = stack.pop()!
|
|
117
|
+
let entries
|
|
118
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
121
|
+
const full = join(dir, entry.name)
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
stack.push(full)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (entry.isFile() && entry.name === 'SKILL.md') {
|
|
127
|
+
const rel = relative(root, dir)
|
|
128
|
+
if (rel && rel !== '.') found.push({ rel, path: full })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return found
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function groupFor(name: string, where: WorkspaceSkill['where']): string {
|
|
136
|
+
const colon = name.indexOf(':')
|
|
137
|
+
if (colon > 0) return name.slice(0, colon).replace(/[-_]/g, ' ').toUpperCase()
|
|
138
|
+
return where === 'user' ? 'USER' : 'WORKSPACE'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function addSkill(
|
|
142
|
+
seen: Map<string, WorkspaceSkill>,
|
|
143
|
+
rel: string,
|
|
144
|
+
path: string,
|
|
145
|
+
where: WorkspaceSkill['where'],
|
|
146
|
+
): void {
|
|
147
|
+
if (seen.size >= SKILLS_CATALOG_MAX) return
|
|
148
|
+
const fromDir = skillNameFromRel(rel)
|
|
149
|
+
if (!fromDir) return
|
|
150
|
+
const parsed = readSkillFile(path)
|
|
151
|
+
if (!parsed) return
|
|
152
|
+
// Directory name is the loader identity. Frontmatter `name` is a label; if it
|
|
153
|
+
// disagrees with the folder (common on generated mirrors), keep the folder.
|
|
154
|
+
const name = parsed.name && basename(rel) === parsed.name ? parsed.name : fromDir
|
|
155
|
+
if (seen.has(name) || excludedName(name)) return
|
|
156
|
+
seen.set(name, {
|
|
157
|
+
name,
|
|
158
|
+
slash: `/${name}`,
|
|
159
|
+
description: parsed.description,
|
|
160
|
+
group: groupFor(name, where),
|
|
161
|
+
where,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function listWorkspaceSkills(options: {
|
|
166
|
+
workDir?: string
|
|
167
|
+
home?: string
|
|
168
|
+
} = {}): WorkspaceSkill[] {
|
|
169
|
+
const workDir = options.workDir ?? resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR })
|
|
170
|
+
const home = options.home ?? homedir()
|
|
171
|
+
const seen = new Map<string, WorkspaceSkill>()
|
|
172
|
+
|
|
173
|
+
if (workDir) {
|
|
174
|
+
const agents = join(workDir, '.agents', 'skills')
|
|
175
|
+
if (existsSync(agents)) {
|
|
176
|
+
for (const skill of walkNestedSkills(agents)) addSkill(seen, skill.rel, skill.path, 'workspace')
|
|
177
|
+
}
|
|
178
|
+
const claude = join(workDir, '.claude', 'skills')
|
|
179
|
+
if (existsSync(claude)) {
|
|
180
|
+
for (const skill of walkImmediateSkills(claude)) addSkill(seen, skill.rel, skill.path, 'workspace')
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const userClaude = join(home, '.claude', 'skills')
|
|
185
|
+
if (existsSync(userClaude)) {
|
|
186
|
+
for (const skill of walkImmediateSkills(userClaude)) addSkill(seen, skill.rel, skill.path, 'user')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [...seen.values()].sort((a, b) => {
|
|
190
|
+
if (a.group !== b.group) {
|
|
191
|
+
if (a.group === 'WORKSPACE') return -1
|
|
192
|
+
if (b.group === 'WORKSPACE') return 1
|
|
193
|
+
if (a.group === 'USER') return 1
|
|
194
|
+
if (b.group === 'USER') return -1
|
|
195
|
+
return a.group.localeCompare(b.group)
|
|
196
|
+
}
|
|
197
|
+
return a.name.localeCompare(b.name)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function workspaceSkillsCatalog(options?: {
|
|
202
|
+
workDir?: string
|
|
203
|
+
home?: string
|
|
204
|
+
}): WorkspaceSkillsCatalog {
|
|
205
|
+
return {
|
|
206
|
+
schemaVersion: SKILLS_CATALOG_SCHEMA,
|
|
207
|
+
skills: listWorkspaceSkills(options),
|
|
208
|
+
}
|
|
209
|
+
}
|
package/server/routes/archive.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Router } from 'express'
|
|
3
3
|
import { listArchiveDateStrings, archiveDir, archiveIndexPath, loadArchive, getArchiveChats, getArchiveDayMessages, appendToArchive } from '../lib/archive.js'
|
|
4
4
|
import { getArchiveChatMessagesNumbered } from './message-ref.js'
|
|
5
|
+
import { ensureArchiveMirrorForDay } from '../lib/conversation.js'
|
|
5
6
|
import { searchArchive, MAX_LIMIT, DEFAULT_LIMIT } from '../lib/archive-search.js'
|
|
6
7
|
import { refreshArchiveIndex } from '../lib/archive-index.js'
|
|
7
8
|
import { getActiveSessions } from '../lib/conversation.js'
|
|
@@ -24,6 +25,8 @@ archiveRouter.param('date', (req, res, next, date) => {
|
|
|
24
25
|
|
|
25
26
|
// GET /api/archive — list all archive dates with summaries
|
|
26
27
|
archiveRouter.get('/archive', async (_req, res) => {
|
|
28
|
+
// 6.45.3 — the listing files yesterday's finished sessions before it answers.
|
|
29
|
+
await ensureArchiveMirrorForDay().catch(() => {})
|
|
27
30
|
// Index-backed. The previous implementation parsed every day file to reach four
|
|
28
31
|
// summary fields; see archive-index.ts for the measurements that killed it.
|
|
29
32
|
const { entries, rebuilt, fromCache } = await refreshArchiveIndex(archiveDir(), archiveIndexPath())
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { Router } from 'express'
|
|
3
3
|
import { readFileSync } from 'fs'
|
|
4
4
|
import { join } from 'path'
|
|
5
|
-
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel } from '../lib/conversation.js'
|
|
5
|
+
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel, ensureArchiveMirrorForDay } from '../lib/conversation.js'
|
|
6
6
|
import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
|
|
7
|
-
import { getArchiveDayMessages } from '../lib/archive.js'
|
|
7
|
+
import { getArchiveDayMessages, listArchiveDateStrings } from '../lib/archive.js'
|
|
8
|
+
import { recentMessagesLimit, selectRecentMessages } from '../lib/recent-messages.js'
|
|
8
9
|
import { localDay } from '../lib/local-day.js'
|
|
9
10
|
import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
|
|
10
11
|
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
@@ -282,16 +283,23 @@ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
|
|
|
282
283
|
res.json({ chats })
|
|
283
284
|
})
|
|
284
285
|
|
|
285
|
-
// GET /api/sessions/today/all-messages —
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
286
|
+
// GET /api/sessions/today/all-messages — the newest messages, live + archived, one window.
|
|
287
|
+
// 6.45.3 — the path keeps its name for the two clients that call it (COS Control's
|
|
288
|
+
// Recent view, the phone's history recovery) but the view is a ROLLING WINDOW of the
|
|
289
|
+
// newest `limit` (default 30, `?limit=` up to 100) across every live session and as many
|
|
290
|
+
// archived days as it takes, not a calendar day. Dedup key is `sessionId|timestamp`
|
|
291
|
+
// (was bare timestamp, which collided on NTP skew or same-ms adds); `sessionId` is
|
|
292
|
+
// always known for live exchanges; archive messages fall back to the archived chat's
|
|
293
|
+
// sessionId via getArchiveDayMessages. `date` stays in the response for compatibility.
|
|
294
|
+
sessionsRouter.get('/sessions/today/all-messages', async (req, res) => {
|
|
295
|
+
// 6.45.3 — first read after midnight files yesterday before answering.
|
|
296
|
+
await ensureArchiveMirrorForDay().catch(() => {})
|
|
290
297
|
const todayDate = localDay()
|
|
298
|
+
const limit = recentMessagesLimit(req.query.limit)
|
|
291
299
|
const activeEra = currentMessageEraState()
|
|
292
300
|
const era = activeEra.era
|
|
293
301
|
|
|
294
|
-
const
|
|
302
|
+
const archivedDay = (date: string) => getArchiveDayMessages(date)
|
|
295
303
|
.filter(m => exchangeBelongsToEra(m, era))
|
|
296
304
|
.map(m => {
|
|
297
305
|
const globalMsgNum = m.globalMsgNum ?? m.no
|
|
@@ -323,8 +331,7 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
323
331
|
}> = []
|
|
324
332
|
const liveSessions = getActiveSessions()
|
|
325
333
|
for (const session of liveSessions) {
|
|
326
|
-
|
|
327
|
-
if (sessionDay !== todayDate) continue
|
|
334
|
+
// Every live session, whatever day it last spoke: the window decides, not the calendar.
|
|
328
335
|
for (let i = 0; i < session.exchanges.length; i++) {
|
|
329
336
|
const ex = session.exchanges[i]
|
|
330
337
|
if (ex.role === 'user') {
|
|
@@ -358,17 +365,13 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
358
365
|
}
|
|
359
366
|
}
|
|
360
367
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
seen.add(k)
|
|
369
|
-
return true
|
|
370
|
-
})
|
|
371
|
-
.sort((a, b) => a.timestamp - b.timestamp)
|
|
368
|
+
// Newest `limit` across live sessions and archived days (newest day first, read
|
|
369
|
+
// only while the window is short), dedup by (sessionId, timestamp), chronological.
|
|
370
|
+
const merged = selectRecentMessages(
|
|
371
|
+
liveMessages as Array<(typeof liveMessages)[number] | ReturnType<typeof archivedDay>[number]>,
|
|
372
|
+
listArchiveDateStrings().map(date => () => archivedDay(date)),
|
|
373
|
+
limit,
|
|
374
|
+
)
|
|
372
375
|
|
|
373
|
-
res.json({ messages: merged, date: todayDate })
|
|
376
|
+
res.json({ messages: merged, date: todayDate, window: { kind: 'recent', limit } })
|
|
374
377
|
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// GET /api/skills — slash commands the glasses agent can run from this Mac.
|
|
2
|
+
// Workspace `.agents/skills` (canonical) + `.claude/skills` + `~/.claude/skills`.
|
|
3
|
+
// Authenticated by the /api token middleware. Paths never leave the box.
|
|
4
|
+
|
|
5
|
+
import { Router } from 'express'
|
|
6
|
+
import { workspaceSkillsCatalog } from '../lib/workspace-skills.js'
|
|
7
|
+
|
|
8
|
+
export const skillsRouter = Router()
|
|
9
|
+
|
|
10
|
+
skillsRouter.get('/skills', (_req, res) => {
|
|
11
|
+
res.set('Cache-Control', 'private, no-store')
|
|
12
|
+
res.json(workspaceSkillsCatalog())
|
|
13
|
+
})
|
package/server/routes/voice.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { dataPath } from '../lib/data-dir.js'
|
|
|
13
13
|
import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
|
|
14
14
|
import { trainingSourceFor } from '../lib/training-audio-provenance.js'
|
|
15
15
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
16
|
+
import { extAudioChunkPath, listExtAudioChunks } from '../lib/meeting-audio-archive.js'
|
|
16
17
|
import { getVoiceDirectorySnapshot, invalidateVoiceDirectory } from '../lib/voice-directory.js'
|
|
17
18
|
import { greedyDiversitySelect } from '../lib/voice-enrolment-selection.js'
|
|
18
19
|
import { fanOutSpeakerRename, type SpeakerRenameFanOut } from '../lib/speaker-rename-fanout.js'
|
|
@@ -336,6 +337,10 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
|
|
|
336
337
|
chunks: wavFiles.length,
|
|
337
338
|
ageHours: parseFloat(ageHours),
|
|
338
339
|
expiresIn: `${Math.max(0, 72 - parseFloat(ageHours)).toFixed(1)}h`,
|
|
340
|
+
// 6.45.3 — the chunk indices a reviewer can ask to hear (`?chunk=` on
|
|
341
|
+
// the sample route). Queen, 2026-09-12: the Add-a-voice panel let her
|
|
342
|
+
// name a session but not listen to it; naming is a guess without this.
|
|
343
|
+
chunkIndices: listExtAudioChunks(d.name),
|
|
339
344
|
}
|
|
340
345
|
}).filter(s => s.chunks > 0)
|
|
341
346
|
|
|
@@ -496,6 +501,8 @@ voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
|
|
|
496
501
|
})
|
|
497
502
|
|
|
498
503
|
// GET /api/voice/ext-audio/:sessionId/sample — hear an unidentified voice.
|
|
504
|
+
// 6.45.3 — `?chunk=<index>` picks one held chunk (indices come from the listing's
|
|
505
|
+
// `chunkIndices`); without it the newest chunk is served, as before.
|
|
499
506
|
voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
|
|
500
507
|
res.set('Cache-Control', 'private, no-store')
|
|
501
508
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -504,6 +511,21 @@ voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
|
|
|
504
511
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
505
512
|
return
|
|
506
513
|
}
|
|
514
|
+
const chunkRaw = req.query.chunk
|
|
515
|
+
if (chunkRaw !== undefined) {
|
|
516
|
+
const chunkIndex = Number.parseInt(String(chunkRaw), 10)
|
|
517
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
518
|
+
res.status(400).json({ error: 'Invalid chunk', reason: 'invalid_chunk' })
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const chunkWav = extAudioChunkPath(sessionId, chunkIndex)
|
|
522
|
+
if (!chunkWav) {
|
|
523
|
+
res.status(404).json({ error: 'No ext-audio retained for that chunk', reason: 'no_ext_audio_chunk' })
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
sendAudioFile(res, chunkWav)
|
|
527
|
+
return
|
|
528
|
+
}
|
|
507
529
|
const wav = existsSync(dirPath) ? newestWav(dirPath) : null
|
|
508
530
|
if (!wav) {
|
|
509
531
|
res.status(404).json({
|