@gotcos/glasses-server 6.36.1 → 6.36.3
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,57 @@ unsaved capture, and makes batch status stop lying about finished work.
|
|
|
2219
2219
|
|
|
2220
2220
|
# Changelog
|
|
2221
2221
|
|
|
2222
|
+
## [6.36.3] - 2026-08-17
|
|
2223
|
+
|
|
2224
|
+
### The seeded query was being crowded out by the steps
|
|
2225
|
+
|
|
2226
|
+
Caught by probing the live stream after shipping 6.36.2, before Miles tested it: a
|
|
2227
|
+
real session seeded 8 events -- 6 tool calls, one prose, one status -- and **no
|
|
2228
|
+
prompt**. The seed took "the last 7 events of any kind", and in a busy run the
|
|
2229
|
+
user's question is twenty or thirty steps back, so the activity you opened the page
|
|
2230
|
+
to watch is exactly what pushed the query off it. The one case the feature exists
|
|
2231
|
+
for was the one case it failed.
|
|
2232
|
+
|
|
2233
|
+
The newest prompt in the read window is now emitted FIRST and unconditionally,
|
|
2234
|
+
outside the step budget. Measured on this Mac's largest transcript (87.2 MB): the
|
|
2235
|
+
last 256 KiB holds 133 records including 3 user turns, so the window reaches a query
|
|
2236
|
+
comfortably. The client pins rather than lists it, so it costs nothing in the
|
|
2237
|
+
scrolling window.
|
|
2238
|
+
|
|
2239
|
+
## [6.36.2] - 2026-08-17
|
|
2240
|
+
|
|
2241
|
+
### The live view stops being a blank slate
|
|
2242
|
+
|
|
2243
|
+
Three changes, all from Miles watching a real session on hardware.
|
|
2244
|
+
|
|
2245
|
+
- **The user's query is now an event.** A `user` record used to be dropped whole,
|
|
2246
|
+
on the reasoning that "the prompt came from this device" -- true of a Continue
|
|
2247
|
+
turn and false of the case that matters most, a session running in a Mac window
|
|
2248
|
+
where that record is the question Miles typed there and the glasses have never
|
|
2249
|
+
seen it. Dropping it is why the lens said WORKING and gave no clue what it was
|
|
2250
|
+
working ON. Tool results stay dropped; harness wrappers
|
|
2251
|
+
(`<system-reminder>`, `<cos-alarms>`, the memory and bulletin blocks) are
|
|
2252
|
+
stripped, because on the lens they would read as the user's own words.
|
|
2253
|
+
New `prompt` kind: additive to a closed set, and safe by construction since the
|
|
2254
|
+
client validates `kind` against its own table and ignores what it does not know.
|
|
2255
|
+
|
|
2256
|
+
- **A shell command is summarised instead of sent raw.** `bash ses...` and
|
|
2257
|
+
`bash s...` on the lens were a command reduced to two characters. Two causes
|
|
2258
|
+
compounding, and this is one of them: the leading `cd <path>` (identical on
|
|
2259
|
+
every command in a repo), heredoc BODIES, and output plumbing (`2>&1`, pipes
|
|
2260
|
+
into `head`/`sed`) are now dropped, keeping the verb and its arguments -- what
|
|
2261
|
+
you would look for reading over someone's shoulder.
|
|
2262
|
+
|
|
2263
|
+
- **The stream seeds from history on connect.** The tail starts at the file's
|
|
2264
|
+
current size, so opening a session that was already working showed an EMPTY page
|
|
2265
|
+
that filled one line at a time. It now reads backward a bounded 256 KiB, drops
|
|
2266
|
+
the leading fragment (an arbitrary offset lands mid-record), and replays the last
|
|
2267
|
+
7 steps -- exactly the client's live window, so the seed fills the screen once
|
|
2268
|
+
without pushing live events out of the view it exists to prime. Never fatal: a
|
|
2269
|
+
session whose history cannot be read still streams, it just starts empty.
|
|
2270
|
+
|
|
2271
|
+
Needs COS Glasses 6.8.374 to render any of it.
|
|
2272
|
+
|
|
2222
2273
|
## [6.36.1] - 2026-08-17
|
|
2223
2274
|
|
|
2224
2275
|
### 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.
|
|
3
|
+
"version": "6.36.3",
|
|
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)
|
|
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,56 @@ 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
|
+
// THE QUERY IS SEEDED SEPARATELY, AND ALWAYS.
|
|
268
|
+
//
|
|
269
|
+
// Taking "the last 7 events" and hoping the prompt is among them does not work,
|
|
270
|
+
// and a live probe against a real session proved it: 8 seeded events, 6 tools and
|
|
271
|
+
// one prose, and NO prompt -- because in a busy run the user's question is twenty
|
|
272
|
+
// or thirty steps back and gets pushed out of the window by the very activity you
|
|
273
|
+
// opened the page to watch. That is the exact case the query exists for.
|
|
274
|
+
//
|
|
275
|
+
// So the newest prompt in the whole read window is emitted FIRST, unconditionally,
|
|
276
|
+
// and the step budget is spent entirely on steps. The client pins it rather than
|
|
277
|
+
// listing it, so it costs nothing in the scrolling window.
|
|
278
|
+
const lastPrompt = [...drafts].reverse().find(d => d.kind === 'prompt')
|
|
279
|
+
if (lastPrompt) write({ ...lastPrompt, at: Date.now() })
|
|
280
|
+
|
|
281
|
+
const steps = drafts.filter(d => d.kind === 'tool' || d.kind === 'prose')
|
|
282
|
+
for (const draft of steps.slice(-SEED_EVENTS)) {
|
|
283
|
+
// NOT tagged as seeded. A replayed step is a step that really happened, and a
|
|
284
|
+
// second rendering style for it would be a distinction without a use. The one
|
|
285
|
+
// consequence is that the client's "N ago" clock starts at open rather than at
|
|
286
|
+
// the record's real time; it self-corrects on the first live event.
|
|
287
|
+
write({ ...draft, at: Date.now() })
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
/* a seed is a nicety; the live tail is the contract */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (closed) return
|
|
294
|
+
|
|
231
295
|
// Then anything published while the headers were being prepared, in order, before
|
|
232
296
|
// the listener starts writing straight through. All three steps are synchronous, so
|
|
233
297
|
// no event can interleave and arrive out of order.
|