@gotcos/glasses-server 6.49.1 → 6.50.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/CHANGELOG.md +8 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/server/lib/agent-session-turns.ts +178 -0
- package/server/routes/agent-sessions.ts +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 6.50.0
|
|
2
|
+
|
|
3
|
+
Scroll back through a running session's conversation on the glasses.
|
|
4
|
+
|
|
5
|
+
- **The recent conversation, served.** Miles, 2026-09-16: "if I come into a session that's been running, I can scroll up and see what was happening, what was the original prompt." COS Control already shows it (its helper parses the transcript on the Mac: last 120 turns, tools omitted); the lens cannot read the Mac's files and had only the live stream, which starts at the current turn. The detail route now answers `?turns=N` (1 to 40) with `recent_turns: [{ role, text, at? }]` and `recent_turns_more`, oldest first: who said what, tool calls, tool results, sub-agents and injected rows left out, a live Continue from the glasses shown as the user's own words, the text shaped exactly as `latest_reply` is. Without the parameter the payload is byte-for-byte what it was, so the detail poll and every older client are untouched; a failure reading the history costs the history, never the page.
|
|
6
|
+
- **Read backward from the end, not from the digest's windows.** The digest reads an oversized transcript as a 256 KiB head plus a 768 KiB tail; on the 42 MB session this was built in, that tail held two prompts, because tool output dominates the bytes (twenty transcripts on this Mac are over 32 MB). The history reads 2, then 8, then at most 24 MiB from the end until it has the messages asked for. Measured on that session: 40 messages, nine prompts back, 8 MB read, 40 ms. A window is read from one byte before its start, so a record that begins exactly at the window's edge is kept (the first cut dropped it; its own test found that).
|
|
7
|
+
- Tests: every row shape for Claude (text, tool call, tool result, sub-agent, thinking, slash-command body, compaction, peer Continue, wrappers, fences, the 4,000 cap), Codex (messages vs developer, calls and events) and Cursor (`<user_query>`); the growing windows, the ceiling, the torn first record, the boundary record; the route (absent unless asked, same fields when asked, oldest first, `more`, a malformed ask ignored). Mutation gate: six new cases.
|
|
8
|
+
|
|
1
9
|
## 6.49.1
|
|
2
10
|
|
|
3
11
|
Where a turn's time goes, and the two things the /qa pass on 6.49.0 found that made a follow-up wait or repeat.
|
package/README.md
CHANGED
|
@@ -224,6 +224,11 @@ waiting out the lease; a binding with a turn in flight still refuses `native_tar
|
|
|
224
224
|
The turn ledger is read across every binding of a thread, so a draft re-sent through a
|
|
225
225
|
new binding (the phone attaches per send; a parked draft drains through the drainer's
|
|
226
226
|
own) replays rather than repeats.
|
|
227
|
+
`GET /api/agent-sessions/:provider/:id?turns=N` (6.50.0, N from 1 to 40) adds
|
|
228
|
+
`recent_turns` (oldest first, `{ role: 'user' | 'assistant', text, at? }`, tools and
|
|
229
|
+
sub-agents omitted) and `recent_turns_more` to the detail payload, read backward from
|
|
230
|
+
the end of the transcript; without `turns` the payload is unchanged. The glasses use it
|
|
231
|
+
to scroll back through a running session's conversation.
|
|
227
232
|
|
|
228
233
|
## Configuration
|
|
229
234
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.50.0",
|
|
4
4
|
"description": "COS Glasses \u2014 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": {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The recent conversation of a session, as the messages a person reads: who spoke and
|
|
2
|
+
// what they said, tool calls and sub-agents left out (6.50.0).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. Miles, 2026-09-16, on the G2: "if I come into a session that's been
|
|
5
|
+
// running, I can scroll up and see what was happening, what was the original prompt
|
|
6
|
+
// that led off to all of this activity." COS Control already shows exactly that ("last
|
|
7
|
+
// 120 of 338 turns · tools omitted"), but its helper reads the transcript file on the
|
|
8
|
+
// Mac itself. The lens cannot read the Mac's files; it gets the live stream (the current
|
|
9
|
+
// turn only) and the 2,000-character digest. This is the same history, served.
|
|
10
|
+
//
|
|
11
|
+
// WHY A READER OF ITS OWN, NOT THE DIGEST'S WINDOWS. `parseAgentSession` reads an
|
|
12
|
+
// oversized transcript as a 256 KiB head plus a 768 KiB tail, which is right for a
|
|
13
|
+
// digest and wrong for history: measured on the 42 MB session this was written in, the
|
|
14
|
+
// 768 KiB tail held TWO prompts (3 user rows, 6 assistant text rows), because tool
|
|
15
|
+
// results dominate the bytes. Twenty transcripts on this Mac are over 32 MB. So this
|
|
16
|
+
// reads backward from the end in growing windows until it has the messages it was asked
|
|
17
|
+
// for, or has covered the file, or reaches its cap.
|
|
18
|
+
//
|
|
19
|
+
// THE RULES MATCH THE DIGEST'S, from the same helpers: a sidechain (sub-agent) row is
|
|
20
|
+
// not the conversation; a tool result is not a message; an injected row (`isMeta`, a
|
|
21
|
+
// compaction summary) is not something anyone said, EXCEPT a peer-inbox row, which is a
|
|
22
|
+
// live Continue the user dictated (6.49.1) and is unwrapped to their words; wrapper
|
|
23
|
+
// prompts are dropped; the text is shaped by `latestAssistantReply`, the function the
|
|
24
|
+
// lens already renders replies through (lines kept, fences and harness tags out, 4,000
|
|
25
|
+
// characters with a visible ellipsis).
|
|
26
|
+
|
|
27
|
+
import { open, stat } from 'node:fs/promises'
|
|
28
|
+
import {
|
|
29
|
+
latestAssistantReply,
|
|
30
|
+
isWrapperPrompt,
|
|
31
|
+
parseJsonLine,
|
|
32
|
+
payloadText,
|
|
33
|
+
type AgentProvider,
|
|
34
|
+
} from './agent-session-store.js'
|
|
35
|
+
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
36
|
+
|
|
37
|
+
export interface SessionTurn {
|
|
38
|
+
role: 'user' | 'assistant'
|
|
39
|
+
text: string
|
|
40
|
+
/** The record's ISO timestamp, when the provider writes one (Cursor does not). */
|
|
41
|
+
at?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a client may ask for in one read. Forty messages is ~10 lens pages of words. */
|
|
45
|
+
export const SESSION_TURNS_MAX = 40
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The windows read backward from the end of the transcript, smallest first.
|
|
49
|
+
*
|
|
50
|
+
* MEASURED on the 42 MB session this was written in: 768 KiB held 2 prompts, 1.75 MiB
|
|
51
|
+
* held 11, 4 MiB held 17 (20 user rows, 56 assistant text rows). So 2 MiB covers an
|
|
52
|
+
* ordinary request for 20 messages in one read, and the larger windows exist for a
|
|
53
|
+
* turn whose tool output is unusually heavy. 24 MiB is the ceiling: past it the answer
|
|
54
|
+
* is what was found, marked as more-before, never a whole-file parse on a request path.
|
|
55
|
+
*/
|
|
56
|
+
export const SESSION_TURNS_WINDOWS: readonly number[] = [2 * 1024 * 1024, 8 * 1024 * 1024, 24 * 1024 * 1024]
|
|
57
|
+
|
|
58
|
+
function iso(value: unknown): string | undefined {
|
|
59
|
+
return typeof value === 'string' && value.length > 0 && !Number.isNaN(Date.parse(value)) ? value : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A Cursor user row wraps the ask in `<user_query>`; the rest is scaffolding. */
|
|
63
|
+
function cursorQuery(text: string): string {
|
|
64
|
+
const start = text.indexOf('<user_query>')
|
|
65
|
+
const end = text.indexOf('</user_query>')
|
|
66
|
+
return start >= 0 && end > start ? text.slice(start + '<user_query>'.length, end) : text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function shaped(role: SessionTurn['role'], raw: string, at: string | undefined): SessionTurn | null {
|
|
70
|
+
if (isWrapperPrompt(raw)) return null
|
|
71
|
+
const text = latestAssistantReply(raw)
|
|
72
|
+
if (!text) return null
|
|
73
|
+
return at ? { role, text, at } : { role, text }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One transcript record as a message a person reads, or null.
|
|
78
|
+
*
|
|
79
|
+
* Pure. The per-provider shapes are the ones `parseAgentSession` reads.
|
|
80
|
+
*/
|
|
81
|
+
export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<string, unknown>): SessionTurn | null {
|
|
82
|
+
if (provider === 'claude') {
|
|
83
|
+
if (obj.isSidechain === true) return null
|
|
84
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') return null
|
|
85
|
+
if (obj.type === 'user' && obj.toolUseResult) return null
|
|
86
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
87
|
+
const text = message ? payloadText(message) : null
|
|
88
|
+
if (!text) return null
|
|
89
|
+
const at = iso(obj.timestamp)
|
|
90
|
+
if (obj.type === 'assistant') return shaped('assistant', text, at)
|
|
91
|
+
const peer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? unwrapPeerMessage(text) : null
|
|
92
|
+
if (peer !== null) return shaped('user', peer, at)
|
|
93
|
+
if (obj.isMeta === true || obj.isCompactSummary === true) return null
|
|
94
|
+
return shaped('user', text, at)
|
|
95
|
+
}
|
|
96
|
+
if (provider === 'codex') {
|
|
97
|
+
if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return null
|
|
98
|
+
const payload = obj.payload as Record<string, unknown>
|
|
99
|
+
if (String(payload.type ?? '') !== 'message') return null
|
|
100
|
+
if (payload.role !== 'user' && payload.role !== 'assistant') return null
|
|
101
|
+
const text = payloadText(payload)
|
|
102
|
+
if (!text) return null
|
|
103
|
+
return shaped(payload.role, text, iso(obj.timestamp))
|
|
104
|
+
}
|
|
105
|
+
if (obj.role !== 'user' && obj.role !== 'assistant') return null
|
|
106
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
107
|
+
const text = message ? payloadText(message) : null
|
|
108
|
+
if (!text) return null
|
|
109
|
+
return shaped(obj.role, obj.role === 'user' ? cursorQuery(text) : text, iso(obj.timestamp))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The newest `limit` messages among these lines (file order in, file order out). */
|
|
113
|
+
export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number): SessionTurn[] {
|
|
114
|
+
const out: SessionTurn[] = []
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
const obj = parseJsonLine(line)
|
|
117
|
+
if (!obj) continue
|
|
118
|
+
const turn = sessionTurnFromRecord(provider, obj)
|
|
119
|
+
if (turn) out.push(turn)
|
|
120
|
+
}
|
|
121
|
+
return limit > 0 ? out.slice(-limit) : []
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface RecentTurnsRead {
|
|
125
|
+
turns: SessionTurn[]
|
|
126
|
+
/** True when the transcript holds messages before the first one returned. */
|
|
127
|
+
more: boolean
|
|
128
|
+
/** Bytes of the transcript this read covered, for the log. */
|
|
129
|
+
bytesRead: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The newest `limit` messages of a transcript, read backward in growing windows.
|
|
134
|
+
* Never parses more than the last window's bytes.
|
|
135
|
+
*/
|
|
136
|
+
export async function readRecentSessionTurns(
|
|
137
|
+
provider: AgentProvider,
|
|
138
|
+
path: string,
|
|
139
|
+
limit: number,
|
|
140
|
+
windows: readonly number[] = SESSION_TURNS_WINDOWS,
|
|
141
|
+
): Promise<RecentTurnsRead> {
|
|
142
|
+
const want = Math.max(0, Math.min(SESSION_TURNS_MAX, Math.floor(limit)))
|
|
143
|
+
const { size } = await stat(path)
|
|
144
|
+
if (want === 0 || size === 0) return { turns: [], more: false, bytesRead: 0 }
|
|
145
|
+
const handle = await open(path, 'r')
|
|
146
|
+
try {
|
|
147
|
+
let found: SessionTurn[] = []
|
|
148
|
+
let start = size
|
|
149
|
+
for (const window of windows) {
|
|
150
|
+
start = Math.max(0, size - window)
|
|
151
|
+
// One byte BEFORE the window as well, so the first line is known to be whole or
|
|
152
|
+
// not: after a newline it splits off as '' and the record starting AT the window
|
|
153
|
+
// is kept; otherwise it is the tail of a record that began earlier and is
|
|
154
|
+
// dropped. Dropping the first line unconditionally lost a whole record whenever a
|
|
155
|
+
// window began exactly on a line start (found by its own test).
|
|
156
|
+
const from = start > 0 ? start - 1 : 0
|
|
157
|
+
const length = size - from
|
|
158
|
+
const buffer = Buffer.alloc(length)
|
|
159
|
+
await handle.read(buffer, 0, length, from)
|
|
160
|
+
const lines = buffer.toString('utf-8').split('\n')
|
|
161
|
+
if (start > 0) lines.shift()
|
|
162
|
+
// One more than wanted, so `more` can be answered from what was read.
|
|
163
|
+
found = recentSessionTurns(provider, lines, want + 1)
|
|
164
|
+
if (found.length > want || start === 0) break
|
|
165
|
+
}
|
|
166
|
+
const more = found.length > want || start > 0
|
|
167
|
+
return { turns: found.slice(-want), more, bytesRead: size - start }
|
|
168
|
+
} finally {
|
|
169
|
+
await handle.close()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `?turns=N`: an integer 1..SESSION_TURNS_MAX, or null (absent or malformed). */
|
|
174
|
+
export function parseTurnsParam(value: unknown): number | null {
|
|
175
|
+
if (typeof value !== 'string' || !/^\d{1,3}$/.test(value)) return null
|
|
176
|
+
const n = Number(value)
|
|
177
|
+
return n >= 1 && n <= SESSION_TURNS_MAX ? n : null
|
|
178
|
+
}
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
type LockHolderSnapshot,
|
|
62
62
|
} from '../lib/occupancy-probes.js'
|
|
63
63
|
import type { OccupancyDirs } from '../lib/thread-occupancy.js'
|
|
64
|
+
import { parseTurnsParam, readRecentSessionTurns } from '../lib/agent-session-turns.js'
|
|
64
65
|
import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
65
66
|
|
|
66
67
|
export const agentSessionsRouter = Router()
|
|
@@ -474,6 +475,18 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
474
475
|
// READ. Presenting a partial count as the session total would be the same
|
|
475
476
|
// dishonesty as a silent cap, so the digest omits the number entirely instead.
|
|
476
477
|
const parsed = await parseAgentSession(provider, found)
|
|
478
|
+
// 6.50.0 `?turns=N`: the recent conversation, for the lens's scroll-up history. Only
|
|
479
|
+
// when asked, so the detail poll and every older client get the same bytes as before.
|
|
480
|
+
// A failure here costs the history, never the detail page.
|
|
481
|
+
const turnsWanted = parseTurnsParam(req.query.turns)
|
|
482
|
+
let recentTurns: Awaited<ReturnType<typeof readRecentSessionTurns>> | null = null
|
|
483
|
+
if (turnsWanted !== null) {
|
|
484
|
+
try {
|
|
485
|
+
recentTurns = await readRecentSessionTurns(provider, found, turnsWanted)
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.warn(`[agent-sessions] recent turns failed provider=${provider}: ${error instanceof Error ? error.message : error}`)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
477
490
|
if (provider === 'cursor') {
|
|
478
491
|
const names = await loadCursorComposerNames(agentSessionRoots().cursorComposerDb)
|
|
479
492
|
const named = names.get(parsed.session_id) || names.get(sessionId)
|
|
@@ -545,6 +558,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
545
558
|
omitted_tools: parsed.omitted_tools,
|
|
546
559
|
...(activity.lastActivityAt ? { last_activity_at: activity.lastActivityAt } : {}),
|
|
547
560
|
...(activity.lastTool ? { last_tool: activity.lastTool } : {}),
|
|
561
|
+
...(recentTurns ? { recent_turns: recentTurns.turns, recent_turns_more: recentTurns.more } : {}),
|
|
548
562
|
})
|
|
549
563
|
} catch (error) {
|
|
550
564
|
console.error(`[agent-sessions] detail failed: ${error instanceof Error ? error.message : error}`)
|