@gotcos/glasses-server 6.36.1 → 6.36.2

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
@@ -2219,6 +2219,40 @@ unsaved capture, and makes batch status stop lying about finished work.
2219
2219
 
2220
2220
  # Changelog
2221
2221
 
2222
+ ## [6.36.2] - 2026-08-17
2223
+
2224
+ ### The live view stops being a blank slate
2225
+
2226
+ Three changes, all from Miles watching a real session on hardware.
2227
+
2228
+ - **The user's query is now an event.** A `user` record used to be dropped whole,
2229
+ on the reasoning that "the prompt came from this device" -- true of a Continue
2230
+ turn and false of the case that matters most, a session running in a Mac window
2231
+ where that record is the question Miles typed there and the glasses have never
2232
+ seen it. Dropping it is why the lens said WORKING and gave no clue what it was
2233
+ working ON. Tool results stay dropped; harness wrappers
2234
+ (`<system-reminder>`, `<cos-alarms>`, the memory and bulletin blocks) are
2235
+ stripped, because on the lens they would read as the user's own words.
2236
+ New `prompt` kind: additive to a closed set, and safe by construction since the
2237
+ client validates `kind` against its own table and ignores what it does not know.
2238
+
2239
+ - **A shell command is summarised instead of sent raw.** `bash ses...` and
2240
+ `bash s...` on the lens were a command reduced to two characters. Two causes
2241
+ compounding, and this is one of them: the leading `cd <path>` (identical on
2242
+ every command in a repo), heredoc BODIES, and output plumbing (`2>&1`, pipes
2243
+ into `head`/`sed`) are now dropped, keeping the verb and its arguments -- what
2244
+ you would look for reading over someone's shoulder.
2245
+
2246
+ - **The stream seeds from history on connect.** The tail starts at the file's
2247
+ current size, so opening a session that was already working showed an EMPTY page
2248
+ that filled one line at a time. It now reads backward a bounded 256 KiB, drops
2249
+ the leading fragment (an arbitrary offset lands mid-record), and replays the last
2250
+ 7 steps -- exactly the client's live window, so the seed fills the screen once
2251
+ without pushing live events out of the view it exists to prime. Never fatal: a
2252
+ session whose history cannot be read still streams, it just starts empty.
2253
+
2254
+ Needs COS Glasses 6.8.374 to render any of it.
2255
+
2222
2256
  ## [6.36.1] - 2026-08-17
2223
2257
 
2224
2258
  ### A reply keeps its line structure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.1",
3
+ "version": "6.36.2",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,14 @@ export type SessionStreamState = 'working' | 'idle' | 'done'
25
25
 
26
26
  export type SessionStreamDraft =
27
27
  | { kind: 'tool'; verb: SessionStreamVerb; target: string; detail: string }
28
+ // The user's own words for the turn being worked on. Miles: "we should see the query
29
+ // that the user has versus it just being a blank slate where it says working. That
30
+ // way, the user at least knows what the agent is actively working on."
31
+ //
32
+ // ADDITIVE TO A CLOSED SET, AND SAFE BY CONSTRUCTION: the client validates `kind`
33
+ // against its own table and ignores anything it does not know, so a build that
34
+ // predates this renders exactly as it did before rather than breaking.
35
+ | { kind: 'prompt'; text: string }
28
36
  | { kind: 'prose'; text: string }
29
37
  | { kind: 'status'; state: SessionStreamState }
30
38
  | { kind: 'heartbeat' }
@@ -52,6 +60,16 @@ export const TARGET_MAX_CHARS = 80
52
60
  /** `+14 -2`, `120 lines`. Anything longer is not a detail. */
53
61
  export const DETAIL_MAX_CHARS = 40
54
62
 
63
+ /**
64
+ * The user's query, in characters.
65
+ *
66
+ * 160 rather than the 80 a target gets: this is the one line that says WHAT IS BEING
67
+ * WORKED ON, so it earns more than a tool name does. The client clips it to the two
68
+ * lens lines it can spare, which at 62 columns is ~120 visible; the extra 40 is
69
+ * headroom so the client rather than the server decides where to cut.
70
+ */
71
+ export const PROMPT_MAX_CHARS = 160
72
+
55
73
  /**
56
74
  * Marker appended when a value was cut.
57
75
  *
@@ -124,6 +142,53 @@ function countLines(value: unknown): number {
124
142
  return value.split('\n').length
125
143
  }
126
144
 
145
+ /**
146
+ * The part of a shell command worth 40 columns.
147
+ *
148
+ * WHAT WENT WRONG ON HARDWARE. Miles's 9:20 screenshot showed `bash ses...` and
149
+ * `bash s...` -- a shell command reduced to two characters. Two causes compounding:
150
+ * the raw command was sent whole, and the CLIENT then treated it as a PATH and kept
151
+ * only the text after the last `/`. So `cd /Users/.../cos-glasses-app && grep -n x
152
+ * src/lib/session-stream-trail.ts` arrived, got split on its final slash, and rendered
153
+ * as the tail of a filename. The client fix is necessary; this is the other half.
154
+ *
155
+ * WHAT IT DROPS, in order of how much noise it removes:
156
+ * - a leading `cd <path>` and its separator. Every command in this repo starts with
157
+ * one and it is the same directory every time: pure cost, zero information.
158
+ * - heredoc BODIES. A `<<'PY' ... PY` block is often hundreds of lines, and none of
159
+ * them is the command; the marker is kept so it is clear a script ran inline.
160
+ * - `2>&1`, pipes into output plumbing (`head`, `tail`, `sed`, `tr`, `cut`) and
161
+ * redirections, which are how you read a command rather than what it does.
162
+ *
163
+ * WHAT IT KEEPS: the first real verb and its arguments, which is what you would look
164
+ * for on a monitor over someone's shoulder.
165
+ */
166
+ export function commandSummary(command: string): string {
167
+ let text = command.replace(/\r/g, '')
168
+
169
+ // Heredoc body out, marker kept: `python3 - <<'PY' ...body... PY` -> `python3 - <<PY`
170
+ text = text.replace(/<<-?\s*'?"?([A-Za-z_][A-Za-z0-9_]*)'?"?[\s\S]*?^\1\s*$/gm, '<<$1')
171
+ text = text.replace(/<<-?\s*'?"?([A-Za-z_][A-Za-z0-9_]*)'?"?[\s\S]*$/m, '<<$1')
172
+
173
+ text = text.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim()
174
+
175
+ // Leading `cd <path>` plus its separator, however the command chained it. Repeated
176
+ // because a command can open with more than one.
177
+ for (let i = 0; i < 3; i++) {
178
+ const next = text.replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;|\|\||\n)?\s*/, '')
179
+ if (next === text) break
180
+ text = next
181
+ }
182
+
183
+ // Output plumbing off the end. The command is what ran, not how it was read.
184
+ text = text.replace(/\s*2>&1\s*/g, ' ')
185
+ text = text.replace(/\s*\|\s*(?:head|tail|sed|tr|cut|wc|sort|uniq|grep -o|cat)\b[^|]*/g, '')
186
+ text = text.replace(/\s*>\s*\/dev\/null(?:\s*2>&1)?/g, '')
187
+
188
+ const flat = text.replace(/\s{2,}/g, ' ').trim()
189
+ return flat.length > 0 ? flat : command.trim()
190
+ }
191
+
127
192
  /**
128
193
  * What this tool acted ON.
129
194
  *
@@ -142,7 +207,9 @@ export function targetForTool(name: unknown, input: unknown): string {
142
207
 
143
208
  if (lower === 'bash' || lower === 'shell' || lower === 'exec' || lower === 'exec_command') {
144
209
  const command = args.command ?? args.cmd
145
- if (typeof command === 'string' && command.length > 0) return oneLine(command, TARGET_MAX_CHARS)
210
+ if (typeof command === 'string' && command.length > 0) {
211
+ return oneLine(commandSummary(command), TARGET_MAX_CHARS)
212
+ }
146
213
  }
147
214
 
148
215
  for (const key of ['pattern', 'query', 'skill', 'description', 'subject', 'prompt']) {
@@ -233,6 +300,41 @@ function draftsFromContentBlocks(message: Record<string, unknown>): SessionStrea
233
300
  return out
234
301
  }
235
302
 
303
+ /**
304
+ * The user's query out of a user record, or nothing.
305
+ *
306
+ * WHAT IS DELIBERATELY NOT A PROMPT:
307
+ * - a `tool_result` block. The call was announced when it was made.
308
+ * - a harness-injected wrapper. `<system-reminder>`, `<local-command-stdout>`,
309
+ * `<command-name>` and the memory/bulletin blocks arrive as user turns and are not
310
+ * anything a person asked. Showing one on the lens would be worse than showing
311
+ * nothing, because it reads as the user's own words.
312
+ * - an empty string after cleaning.
313
+ */
314
+ export function promptDrafts(message: Record<string, unknown>): SessionStreamDraft[] {
315
+ const content = message.content
316
+ const blocks = Array.isArray(content)
317
+ ? content
318
+ : typeof content === 'string' ? [{ type: 'text', text: content }] : []
319
+
320
+ const parts: string[] = []
321
+ for (const raw of blocks) {
322
+ const block = asRecord(raw)
323
+ if (!block) continue
324
+ if (block.type === 'tool_result') return []
325
+ if (block.type !== 'text' && block.type !== undefined) continue
326
+ if (typeof block.text === 'string') parts.push(block.text)
327
+ }
328
+
329
+ let text = parts.join(' ')
330
+ // Wrapper blocks out, whole. A partial strip would leave the tag names on the lens.
331
+ text = text.replace(/<(system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args)>[\s\S]*?<\/\1>/g, ' ')
332
+ const flat = oneLine(text, PROMPT_MAX_CHARS)
333
+ // A record whose ONLY content was a wrapper leaves nothing worth a line.
334
+ if (!flat || /^</.test(flat)) return []
335
+ return [{ kind: 'prompt', text: flat }]
336
+ }
337
+
236
338
  function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamDraft[] {
237
339
  const type = typeof record.type === 'string' ? record.type : ''
238
340
 
@@ -241,9 +343,19 @@ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamD
241
343
  if (type === 'system' && record.subtype === 'init') return [{ kind: 'status', state: 'working' }]
242
344
  if (type === 'result') return [{ kind: 'status', state: 'done' }]
243
345
 
244
- // A user row is a tool RESULT or the prompt we just sent. Neither is news: the tool
245
- // call was already announced, and the prompt came from this device.
246
- if (type === 'user') return []
346
+ // A user row is EITHER a tool result or the query being worked on.
347
+ //
348
+ // This used to drop both, on the reasoning that "the prompt came from this device".
349
+ // That is true of a Continue turn and FALSE of the case that matters most: a session
350
+ // running in a Mac window, where the user row is the question Miles typed there and
351
+ // the glasses have never seen it. Dropping it is what made the live view a blank
352
+ // slate that said WORKING and nothing else.
353
+ //
354
+ // Tool results stay dropped -- the call was already announced.
355
+ if (type === 'user') {
356
+ const message = asRecord(record.message)
357
+ return message ? promptDrafts(message) : []
358
+ }
247
359
 
248
360
  const role = typeof record.role === 'string' ? record.role : ''
249
361
  if (type !== 'assistant' && role !== 'assistant') return []
@@ -172,6 +172,60 @@ async function readRangeAt(path: string, offset: number, length: number): Promis
172
172
  }
173
173
  }
174
174
 
175
+ /**
176
+ * The last complete records before a byte offset, for the OPEN-time seed.
177
+ *
178
+ * WHY THIS EXISTS. The tail starts at the file's current size, so opening a session
179
+ * that is already working showed an EMPTY page that filled one line at a time. Miles,
180
+ * on hardware: "the live session pulling is a little lackluster." You walk up to the
181
+ * desk and the monitor is blank. The history is right there in the file; the tailer was
182
+ * simply choosing not to read it.
183
+ *
184
+ * READS BACKWARD, BOUNDED. `maxBytes` off the end, never the whole file -- the whole
185
+ * point of the forward-cursor design is that an 81 MB transcript is never re-read.
186
+ *
187
+ * DROPS THE FIRST FRAGMENT. A read from an arbitrary offset lands MID-RECORD, so the
188
+ * bytes before the first newline are the tail of a record whose start we never saw.
189
+ * Emitting that fragment would put a half-parsed line on the lens; it is discarded,
190
+ * which is why this returns "the last COMPLETE records".
191
+ *
192
+ * Returns [] on any read failure. A seed is a nicety; a session must still stream when
193
+ * its history cannot be read.
194
+ */
195
+ export async function readTranscriptSeedLines(
196
+ path: string,
197
+ endOffset: number,
198
+ maxBytes = SEED_MAX_BYTES,
199
+ ): Promise<string[]> {
200
+ const end = Math.max(0, endOffset)
201
+ if (end === 0) return []
202
+ const length = Math.min(end, Math.max(0, maxBytes))
203
+ const start = end - length
204
+ const chunk = await readRangeAt(path, start, length)
205
+ if (chunk === null || chunk.length === 0) return []
206
+
207
+ let buf = chunk
208
+ if (start > 0) {
209
+ // Mid-record start: everything up to and including the first newline belongs to a
210
+ // record we did not see the beginning of.
211
+ const first = buf.indexOf(0x0a)
212
+ if (first < 0) return []
213
+ buf = buf.subarray(first + 1)
214
+ }
215
+ const last = buf.lastIndexOf(0x0a)
216
+ if (last < 0) return []
217
+ return buf.subarray(0, last).toString('utf8').split('\n').filter(line => line.length > 0)
218
+ }
219
+
220
+ /**
221
+ * Bytes read backward for a seed.
222
+ *
223
+ * 256 KiB against a measured p90 record of 1,627 bytes is on the order of a hundred
224
+ * records -- far more than the seven the lens can show, and small enough that the read
225
+ * is one syscall on a page open rather than anything the user waits for.
226
+ */
227
+ export const SEED_MAX_BYTES = 256 * 1024
228
+
175
229
  export function createTranscriptTailer(options: TranscriptTailerOptions): TranscriptTailer {
176
230
  const now = options.now ?? (() => Date.now())
177
231
  const publish = options.publish ?? ((key, draft) => { publishSessionStream(key, draft) })
@@ -65,7 +65,12 @@ import {
65
65
  subscribeSessionStream,
66
66
  type PublishedSessionEvent,
67
67
  } from '../lib/session-stream-bus.js'
68
- import { acquireTranscriptWatcher, transcriptWatcherDegraded } from '../lib/session-transcript-watcher.js'
68
+ import {
69
+ acquireTranscriptWatcher,
70
+ transcriptWatcherDegraded,
71
+ readTranscriptSeedLines,
72
+ } from '../lib/session-transcript-watcher.js'
73
+ import { draftsFromLine } from '../lib/session-stream-events.js'
69
74
  import type { SessionStreamState } from '../lib/session-stream-events.js'
70
75
 
71
76
  export const agentSessionStreamRouter = Router()
@@ -108,6 +113,15 @@ export async function openingState(
108
113
  }
109
114
  }
110
115
 
116
+ /**
117
+ * Seeded events written on connect.
118
+ *
119
+ * EXACTLY THE LIVE WINDOW. `SESSION_TRAIL_LIVE_LINES` on the client is 7, measured
120
+ * against the 220px body; seeding more would scroll the newest events out of the view
121
+ * the seed exists to fill, and seeding fewer would leave the screen half empty.
122
+ */
123
+ export const SEED_EVENTS = 7
124
+
111
125
  agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', async (req, res) => {
112
126
  res.set('Cache-Control', 'private, no-store')
113
127
 
@@ -228,6 +242,42 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
228
242
  // The contract's "emit a status immediately" -- written before any queued event so
229
243
  // the client's first frame is always a state, never a bare tool line.
230
244
  write({ kind: 'status', state, at: Date.now() })
245
+ // THE SEED. The last few steps of what already happened, before anything live.
246
+ //
247
+ // Without it, opening a session that is already working shows an empty page that
248
+ // fills one line at a time, which is what Miles reported from hardware. The screen
249
+ // should look like a monitor you just walked up to, not one that was switched on.
250
+ //
251
+ // BOUNDED TO WHAT THE LENS CAN SHOW. `SEED_EVENTS` is the live window, so the seed
252
+ // fills the screen once and no more: a hundred replayed events would push the live
253
+ // ones off the top of the very view they are meant to prime.
254
+ //
255
+ // ORDERED BEFORE `deliver = write`, so a live record landing during the read is
256
+ // queued in `pending` and written AFTER the seed rather than being overtaken by it.
257
+ //
258
+ // NEVER FATAL. A session whose history cannot be read still streams; it just starts
259
+ // empty, exactly as it did before this existed.
260
+ if (path !== null && startOffset > 0) {
261
+ try {
262
+ const lines = await readTranscriptSeedLines(path, startOffset)
263
+ const drafts = lines.flatMap(line => draftsFromLine(provider, line))
264
+ // Status drafts are dropped from the seed: they describe the state at some past
265
+ // moment and the opening status above is the CURRENT one. Replaying an old
266
+ // `done` after it would tell the client the live session had finished.
267
+ const steps = drafts.filter(d => d.kind === 'tool' || d.kind === 'prose' || d.kind === 'prompt')
268
+ for (const draft of steps.slice(-SEED_EVENTS)) {
269
+ // NOT tagged as seeded. A replayed step is a step that really happened, and a
270
+ // second rendering style for it would be a distinction without a use. The one
271
+ // consequence is that the client's "N ago" clock starts at open rather than at
272
+ // the record's real time; it self-corrects on the first live event.
273
+ write({ ...draft, at: Date.now() })
274
+ }
275
+ } catch {
276
+ /* a seed is a nicety; the live tail is the contract */
277
+ }
278
+ }
279
+ if (closed) return
280
+
231
281
  // Then anything published while the headers were being prepared, in order, before
232
282
  // the listener starts writing straight through. All three steps are synchronous, so
233
283
  // no event can interleave and arrive out of order.