@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 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.49.1",
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}`)