@gotcos/glasses-server 6.36.0 → 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,65 @@ 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
|
+
|
|
2256
|
+
## [6.36.1] - 2026-08-17
|
|
2257
|
+
|
|
2258
|
+
### A reply keeps its line structure
|
|
2259
|
+
|
|
2260
|
+
Miles, from a G2 screenshot: a session reply arrived on the lens as one unbroken
|
|
2261
|
+
paragraph carrying three headings and six bullets, none of them visible.
|
|
2262
|
+
|
|
2263
|
+
`proseBody` collapsed ALL whitespace, and both the one-line list gist and the
|
|
2264
|
+
`latest_reply` BODY went through it. Collapsing is right for a row and destroys a
|
|
2265
|
+
body: the client cannot restore structure the server already flattened. Two
|
|
2266
|
+
fields, two jobs, and now two paths — `latestAssistantReply` preserves newlines
|
|
2267
|
+
while `proseSnippet` still returns exactly one line.
|
|
2268
|
+
|
|
2269
|
+
### The tag strip ate prose
|
|
2270
|
+
|
|
2271
|
+
`/<[^>]+>/` deleted anything between angle brackets, so `read <file>` reached the
|
|
2272
|
+
lens as `read ,`. Every `<path>`, `<PORT>` and `<name>` a technical reply uses
|
|
2273
|
+
died the same way, mid-sentence and unreportably. Replaced with an allowlist built
|
|
2274
|
+
from the tags actually present in transcripts on this machine (measured over 3,001
|
|
2275
|
+
records: HTML from rendered output, plus the COS wrapper blocks). A name not on
|
|
2276
|
+
the list is treated as the prose it almost always is; adding one later is a
|
|
2277
|
+
one-line change, whereas a placeholder eaten out of a sentence is invisible.
|
|
2278
|
+
|
|
2279
|
+
9 execution tests on the shared prose path.
|
|
2280
|
+
|
|
2222
2281
|
## [6.36.0] - 2026-08-17
|
|
2223
2282
|
|
|
2224
2283
|
### Sessions push instead of being polled
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.36.
|
|
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": {
|
|
@@ -122,12 +122,57 @@ export function firstLineTitle(text: string): string {
|
|
|
122
122
|
return line.slice(0, 80)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Tags worth deleting, as opposed to any pair of angle brackets.
|
|
127
|
+
*
|
|
128
|
+
* WHY A LIST AND NOT `/<[^>]+>/`. That pattern deleted PROSE. `read <file>` in an
|
|
129
|
+
* assistant reply rendered on the lens as `read ,` because `<file>` looks exactly like
|
|
130
|
+
* a tag; `<path>`, `<PORT>`, `<name>` and every other placeholder a technical answer
|
|
131
|
+
* uses died the same way, silently, mid-sentence.
|
|
132
|
+
*
|
|
133
|
+
* These are the names actually present in transcripts on this machine (measured over
|
|
134
|
+
* 3,001 records: HTML from rendered output, plus the COS wrapper blocks the harness
|
|
135
|
+
* injects). Anything not named here is treated as the prose it almost always is.
|
|
136
|
+
* A name that shows up later and should be stripped is a one-line addition; a
|
|
137
|
+
* placeholder eaten out of a sentence is invisible and unreportable.
|
|
138
|
+
*/
|
|
139
|
+
const STRIPPABLE_TAGS = [
|
|
140
|
+
// HTML that reaches a transcript through rendered or pasted output
|
|
141
|
+
'div', 'p', 'span', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'br', 'hr',
|
|
142
|
+
'code', 'pre', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
|
143
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'img', 'summary', 'details',
|
|
144
|
+
// COS / harness wrapper blocks
|
|
145
|
+
'now', 'relevant-memories', 'cache-health', 'daily-bulletin', 'cos-alarms',
|
|
146
|
+
'device-handoff', 'system-reminder', 'memory-stored', 'user_query',
|
|
147
|
+
'task-notification', 'task-id', 'tool-use-id', 'output-file', 'status',
|
|
148
|
+
'result', 'usage', 'subagent_tokens', 'tool_uses', 'duration_ms',
|
|
149
|
+
'example', 'commentary', 'string', 'functions', 'function', 'command-name',
|
|
150
|
+
'local-command-stdout', 'local-command-stderr', 'thinking',
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fenced code and known tags out. Shared so `proseSnippet` and `latestAssistantReply`
|
|
157
|
+
* can never disagree about what the prose of a record IS.
|
|
158
|
+
*
|
|
159
|
+
* `keepLines` is the whole difference between them, and it was the bug. A LIST ROW is
|
|
160
|
+
* one line and must collapse; a BODY is what the reader paginates and its line
|
|
161
|
+
* structure IS the formatting. Collapsing both through one path turned every heading,
|
|
162
|
+
* bullet and paragraph break in a reply into a space, and delivered a wall of prose to
|
|
163
|
+
* a 576x288 lens with no structure left for the client to lay out.
|
|
164
|
+
*/
|
|
165
|
+
function proseBody(text: string, keepLines = false): string {
|
|
166
|
+
const body = text.replace(/```[\s\S]*?```/g, ' ').replace(STRIPPABLE_TAG_RE, ' ')
|
|
167
|
+
if (!keepLines) return body.replace(/\s+/g, ' ').trim()
|
|
168
|
+
return body
|
|
169
|
+
// Spaces and tabs collapse; newlines do not.
|
|
170
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
171
|
+
// Trailing space before a break would render as a hanging indent on the lens.
|
|
172
|
+
.replace(/ *\n/g, '\n')
|
|
173
|
+
// Three or more breaks is never meaningful and costs a reader page.
|
|
174
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
175
|
+
.trim()
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
export function proseSnippet(text: string, max = 160): string {
|
|
@@ -166,7 +211,10 @@ export const LATEST_REPLY_MAX = 4000
|
|
|
166
211
|
* to say so.
|
|
167
212
|
*/
|
|
168
213
|
export function latestAssistantReply(text: string, max = LATEST_REPLY_MAX): string {
|
|
169
|
-
|
|
214
|
+
// LINE STRUCTURE PRESERVED. This is the field the reader renders, so its headings,
|
|
215
|
+
// bullets and paragraph breaks have to survive the trip; the client cannot restore
|
|
216
|
+
// structure the server already flattened.
|
|
217
|
+
const body = proseBody(text, true)
|
|
170
218
|
if (!body || isWrapperPrompt(body)) return ''
|
|
171
219
|
return body.length <= max ? body : `${body.slice(0, max - 1)}…`
|
|
172
220
|
}
|
|
@@ -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)
|
|
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
|
|
245
|
-
//
|
|
246
|
-
|
|
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 {
|
|
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.
|