@gotcos/glasses-server 6.49.1 → 6.50.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 CHANGED
@@ -1,3 +1,21 @@
1
+ ## 6.50.1
2
+
3
+ What the QA pass on 6.50.0 found in the history it serves (6.50.0 was published before the pass finished; nothing it changed regressed, and a client that does not ask for `?turns` sees no difference).
4
+
5
+ - **Another agent's message is not the user.** A peer-inbox frame ("Another Claude session sent a message: …") is the user's words only when it is a live Continue (`origin.kind: 'peer'`). Teammate reports, idle notifications and other sessions' messages use the same frame on a plain user row with no origin; 20 of them in one real session were served as `role: 'user'` and would have read "YOU" on the lens. They are left out now.
6
+ - **An injected user row never replaces the prompt on the live stream.** An image attachment's `[Image: source: …]`, a skill's "Base directory for this skill" and a compaction summary are written as `isMeta` / `isCompactSummary` user rows right after the real prompt, and each replaced it on the lens's context line (23 of 91 prompt changes in one session). They emit no prompt now; a live Continue (also `isMeta`) still does. This is a fix to the stream's prompt line since 6.48.x, and it lets the glasses match the current turn when they cut the history.
7
+ - **User rows no person typed are left out too:** Claude's `[Request interrupted by user]` marker and the `# AGENTS.md instructions for …` block Codex writes as a user message.
8
+ - **`?turns` above the maximum is clamped to 40**, not refused: a client asking for more gets the most this server serves instead of looking like an older server.
9
+ - Tests: the teammate frame and a plain other-session frame dropped, an interrupted tool row with text dropped, the injected rows emit no prompt while a Continue and a real prompt do, the clamp, and a route test where the history read throws (the page answers 200 without the fields; QA found that promise untested). Mutation gate: four new cases.
10
+
11
+ ## 6.50.0
12
+
13
+ Scroll back through a running session's conversation on the glasses.
14
+
15
+ - **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.
16
+ - **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).
17
+ - 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.
18
+
1
19
  ## 6.49.1
2
20
 
3
21
  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.1",
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,195 @@
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
+ /**
70
+ * User rows that no person typed (6.50.1, QA on 6.50.0): Claude's interrupt marker
71
+ * ("[Request interrupted by user]", 7 in one session) and the AGENTS.md block Codex
72
+ * writes as a user message (a 4,000-character "# AGENTS.md instructions for …" row in
73
+ * 3 of the last 60 rollouts). Each read as "YOU" on the lens.
74
+ */
75
+ const NOT_TYPED_BY_THE_USER = /^\s*(?:\[Request interrupted by user|# AGENTS\.md instructions\b)/
76
+
77
+ function shaped(role: SessionTurn['role'], raw: string, at: string | undefined): SessionTurn | null {
78
+ if (isWrapperPrompt(raw)) return null
79
+ if (role === 'user' && NOT_TYPED_BY_THE_USER.test(raw)) return null
80
+ const text = latestAssistantReply(raw)
81
+ if (!text) return null
82
+ return at ? { role, text, at } : { role, text }
83
+ }
84
+
85
+ /**
86
+ * One transcript record as a message a person reads, or null.
87
+ *
88
+ * Pure. The per-provider shapes are the ones `parseAgentSession` reads.
89
+ */
90
+ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<string, unknown>): SessionTurn | null {
91
+ if (provider === 'claude') {
92
+ if (obj.isSidechain === true) return null
93
+ if (obj.type !== 'user' && obj.type !== 'assistant') return null
94
+ if (obj.type === 'user' && obj.toolUseResult) return null
95
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
96
+ const text = message ? payloadText(message) : null
97
+ if (!text) return null
98
+ const at = iso(obj.timestamp)
99
+ if (obj.type === 'assistant') return shaped('assistant', text, at)
100
+ // A peer-inbox message. Only a live Continue (`origin.kind: 'peer'`, 6.49.x) is the
101
+ // user's own words; every other message in that frame is another agent talking
102
+ // (a teammate's report, an idle notification, another session's SendMessage),
103
+ // written as a plain user row with no origin. QA found 20 of those in one session
104
+ // rendering as "YOU".
105
+ const peer = unwrapPeerMessage(text)
106
+ if (peer !== null) return (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? shaped('user', peer, at) : null
107
+ if (obj.isMeta === true || obj.isCompactSummary === true) return null
108
+ return shaped('user', text, at)
109
+ }
110
+ if (provider === 'codex') {
111
+ if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return null
112
+ const payload = obj.payload as Record<string, unknown>
113
+ if (String(payload.type ?? '') !== 'message') return null
114
+ if (payload.role !== 'user' && payload.role !== 'assistant') return null
115
+ const text = payloadText(payload)
116
+ if (!text) return null
117
+ return shaped(payload.role, text, iso(obj.timestamp))
118
+ }
119
+ if (obj.role !== 'user' && obj.role !== 'assistant') return null
120
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
121
+ const text = message ? payloadText(message) : null
122
+ if (!text) return null
123
+ return shaped(obj.role, obj.role === 'user' ? cursorQuery(text) : text, iso(obj.timestamp))
124
+ }
125
+
126
+ /** The newest `limit` messages among these lines (file order in, file order out). */
127
+ export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number): SessionTurn[] {
128
+ const out: SessionTurn[] = []
129
+ for (const line of lines) {
130
+ const obj = parseJsonLine(line)
131
+ if (!obj) continue
132
+ const turn = sessionTurnFromRecord(provider, obj)
133
+ if (turn) out.push(turn)
134
+ }
135
+ return limit > 0 ? out.slice(-limit) : []
136
+ }
137
+
138
+ export interface RecentTurnsRead {
139
+ turns: SessionTurn[]
140
+ /** True when the transcript holds messages before the first one returned. */
141
+ more: boolean
142
+ /** Bytes of the transcript this read covered (tests assert the window growth by it). */
143
+ bytesRead: number
144
+ }
145
+
146
+ /**
147
+ * The newest `limit` messages of a transcript, read backward in growing windows.
148
+ * Never parses more than the last window's bytes.
149
+ */
150
+ export async function readRecentSessionTurns(
151
+ provider: AgentProvider,
152
+ path: string,
153
+ limit: number,
154
+ windows: readonly number[] = SESSION_TURNS_WINDOWS,
155
+ ): Promise<RecentTurnsRead> {
156
+ const want = Math.max(0, Math.min(SESSION_TURNS_MAX, Math.floor(limit)))
157
+ const { size } = await stat(path)
158
+ if (want === 0 || size === 0) return { turns: [], more: false, bytesRead: 0 }
159
+ const handle = await open(path, 'r')
160
+ try {
161
+ let found: SessionTurn[] = []
162
+ let start = size
163
+ for (const window of windows) {
164
+ start = Math.max(0, size - window)
165
+ // One byte BEFORE the window as well, so the first line is known to be whole or
166
+ // not: after a newline it splits off as '' and the record starting AT the window
167
+ // is kept; otherwise it is the tail of a record that began earlier and is
168
+ // dropped. Dropping the first line unconditionally lost a whole record whenever a
169
+ // window began exactly on a line start (found by its own test).
170
+ const from = start > 0 ? start - 1 : 0
171
+ const length = size - from
172
+ const buffer = Buffer.alloc(length)
173
+ await handle.read(buffer, 0, length, from)
174
+ const lines = buffer.toString('utf-8').split('\n')
175
+ if (start > 0) lines.shift()
176
+ // One more than wanted, so `more` can be answered from what was read.
177
+ found = recentSessionTurns(provider, lines, want + 1)
178
+ if (found.length > want || start === 0) break
179
+ }
180
+ const more = found.length > want || start > 0
181
+ return { turns: found.slice(-want), more, bytesRead: size - start }
182
+ } finally {
183
+ await handle.close()
184
+ }
185
+ }
186
+
187
+ /** `?turns=N`: an integer, clamped to 1..SESSION_TURNS_MAX; null when absent, zero or
188
+ * malformed. Clamped rather than refused above the max, so a client asking for more
189
+ * than this server serves gets the most it can have instead of looking like an older
190
+ * server with no history at all. */
191
+ export function parseTurnsParam(value: unknown): number | null {
192
+ if (typeof value !== 'string' || !/^\d{1,3}$/.test(value)) return null
193
+ const n = Number(value)
194
+ return n >= 1 ? Math.min(n, SESSION_TURNS_MAX) : null
195
+ }
@@ -559,7 +559,16 @@ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamD
559
559
  const message = asRecord(record.message)
560
560
  if (!message) return []
561
561
  const outcomes = outcomeDrafts(record, message)
562
- return outcomes.length > 0 ? outcomes : promptDrafts(message)
562
+ if (outcomes.length > 0) return outcomes
563
+ // An INJECTED user row is not the query (6.50.0): an image attachment's
564
+ // "[Image: source: …]", a skill's "Base directory for this skill", a compaction
565
+ // summary. Each is written as `isMeta` / `isCompactSummary` right after the real
566
+ // prompt and used to replace it on the lens's context line (23 of 91 prompt changes
567
+ // in one real session). A live Continue is the exception: it is `isMeta` and IS
568
+ // the user's words.
569
+ const liveContinue = (record.origin as { kind?: unknown } | undefined)?.kind === 'peer'
570
+ if ((record.isMeta === true || record.isCompactSummary === true) && !liveContinue) return []
571
+ return promptDrafts(message)
563
572
  }
564
573
 
565
574
  const role = typeof record.role === 'string' ? record.role : ''
@@ -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, type RecentTurnsRead } 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: RecentTurnsRead | 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}`)