@gotcos/glasses-server 6.50.0 → 6.50.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
@@ -1,3 +1,20 @@
1
+ ## 6.50.2
2
+
3
+ - **The Turbo meeting preview no longer switches itself off for good.** Miles, 2026-09-18 08:43: the `~` provisional line on the lens was gone, Mac-reachable, Turbo configured. The preview route had answered "no text" to 5,719 requests since 2026-09-17 16:00 (in under a millisecond) while the Turbo sidecar itself transcribed the same audio in 0.27 s. One failed request (`The operation was aborted due to timeout`, then `fetch failed`, with the Mac under build load) set `previewAvailable = false`, and the only code that sets it back is the sidecar's own start, which runs once per server boot. Now a failure against a sidecar that is still running is a 10 s cooldown (`PREVIEW_FAILURE_COOLDOWN_MS`) and the next request after it tries again; five failures in a row (`PREVIEW_RECYCLE_AFTER_FAILURES`) recycle the sidecar; a sidecar that exited is restarted from the request path, at most once per 60 s window, doubling to 15 min while restarts keep failing and resetting on any answer. A missing model, a missing binary or a foreign listener on port 8177 is never restarted (a restart cannot fix it). After the server's own shutdown stop, nothing revives the sidecar (a request arriving during shutdown could otherwise have spawned one that outlived the process). Prompt previews were never affected (they fall back to the canonical worker); canonical meeting ASR never was. The preview log lines now carry an ISO timestamp: the lines that recorded this failure had none.
4
+ - Tests (`whisper-preview.test.ts`, executed against spawn and fetch fakes and a fake clock): a timeout is a cooldown and the next request after it gets text; a success resets the count so scattered misses never recycle; five in a row recycle and the new sidecar answers; an exited sidecar restarts from the request path and the backoff doubles across failed restarts; a foreign listener is never re-probed. Eight mutants (latch restored, success never resets, no revive, backoff never doubles, cooldown ignored, foreign port restarted, revive after shutdown, start after shutdown) each turn the suite red; three of them survived the first draft of the tests and the tests were tightened (a recycle's kill is synchronous, its respawn is not, so negatives now wait for the loop to settle).
5
+ - **Coming back to a Desktop tab no longer blocks a Continue for 30 seconds.** Miles, 2026-09-17 07:00: five sends from the glasses refused "Your Mac is writing to this thread right now" until he clicked away from the tab. Claude Desktop had written its away summary (the note it drops when you return to a tab you left) to the transcript 11 s earlier, and the sub-agent that wrote it fired `SubagentStop`. The attach gate reads a transcript written inside 30 s as working unless the hooks vouch that the turn ended, and the vouch required the newest hook event to be the `Stop` itself, so the desk's own bookkeeping after it made the gate strict. The vouch now reads "no turn since the Stop": a `SubagentStop` with no sub-agent open, or a notification, keeps it; anything that opens a turn (a prompt, a tool, a sub-agent starting) still refuses, and a newer Stop still moves the clock.
6
+ - Tests: the two bookkeeping events keep the vouch and each other guard still holds behind them; a tool, a prompt, a sub-agent start and a permission request stay strict. Gate: `turns-b6-desk-bookkeeping-is-strict-again` and `turns-b6-any-event-vouches` added; the four B6 cases killed.
7
+
8
+ ## 6.50.1
9
+
10
+ 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).
11
+
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+ - **`?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.
16
+ - 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.
17
+
1
18
  ## 6.50.0
2
19
 
3
20
  Scroll back through a running session's conversation on the glasses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.50.0",
3
+ "version": "6.50.2",
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": {
package/server/index.ts CHANGED
@@ -858,7 +858,7 @@ async function gracefulShutdown(): Promise<void> {
858
858
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
859
859
  stopCodexModelCatalogRefresh()
860
860
  stopWhisperServer()
861
- await stopWhisperPreviewServer()
861
+ await stopWhisperPreviewServer({ final: true })
862
862
  stopLocalTtsServer()
863
863
  clearTimeout(forceExit)
864
864
  process.exit(0)
@@ -66,8 +66,17 @@ function cursorQuery(text: string): string {
66
66
  return start >= 0 && end > start ? text.slice(start + '<user_query>'.length, end) : text
67
67
  }
68
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
+
69
77
  function shaped(role: SessionTurn['role'], raw: string, at: string | undefined): SessionTurn | null {
70
78
  if (isWrapperPrompt(raw)) return null
79
+ if (role === 'user' && NOT_TYPED_BY_THE_USER.test(raw)) return null
71
80
  const text = latestAssistantReply(raw)
72
81
  if (!text) return null
73
82
  return at ? { role, text, at } : { role, text }
@@ -88,8 +97,13 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
88
97
  if (!text) return null
89
98
  const at = iso(obj.timestamp)
90
99
  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)
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
93
107
  if (obj.isMeta === true || obj.isCompactSummary === true) return null
94
108
  return shaped('user', text, at)
95
109
  }
@@ -125,7 +139,7 @@ export interface RecentTurnsRead {
125
139
  turns: SessionTurn[]
126
140
  /** True when the transcript holds messages before the first one returned. */
127
141
  more: boolean
128
- /** Bytes of the transcript this read covered, for the log. */
142
+ /** Bytes of the transcript this read covered (tests assert the window growth by it). */
129
143
  bytesRead: number
130
144
  }
131
145
 
@@ -170,9 +184,12 @@ export async function readRecentSessionTurns(
170
184
  }
171
185
  }
172
186
 
173
- /** `?turns=N`: an integer 1..SESSION_TURNS_MAX, or null (absent or malformed). */
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. */
174
191
  export function parseTurnsParam(value: unknown): number | null {
175
192
  if (typeof value !== 'string' || !/^\d{1,3}$/.test(value)) return null
176
193
  const n = Number(value)
177
- return n >= 1 && n <= SESSION_TURNS_MAX ? n : null
194
+ return n >= 1 ? Math.min(n, SESSION_TURNS_MAX) : null
178
195
  }
@@ -792,15 +792,28 @@ export function buildOccupancyProbes(
792
792
  *
793
793
  * `signalFor` is the signal store's reader, taking a full session id or the registry's
794
794
  * 8-character form; `registryIdleAfterStop` reads the registry record for the session.
795
- * The probe answers a Stop time only when BOTH vouch: the session's newest hook event is
796
- * that Stop, the turn is closed, no sub-agent is open, the session has not ended, and
797
- * the registry says idle at or after the Stop (the hooks have returned). Codex never
795
+ * The probe answers a Stop time only when BOTH vouch: nothing since the Stop has opened
796
+ * a turn (the desk's own bookkeeping after it does not count: the sub-agent that writes
797
+ * Claude Desktop's away summary, an idle notification), the turn is closed, no sub-agent
798
+ * is open, the session has not ended, and the registry says idle at or after the Stop
799
+ * (the hooks have returned). Codex never
798
800
  * asks (its ids are not Claude sessions, and a Codex thread id sharing a Claude prefix
799
801
  * must not read a Claude signal); `readHolderActivity` guards the provider too.
800
802
  *
801
803
  * Wired at the composition root only when `COS_SESSION_HOOKS` is on. Absent, the
802
804
  * gate is byte-for-byte 6.48.0.
803
805
  */
806
+ /**
807
+ * Hook events that can follow a Stop without a turn having started (6.50.2). Miles,
808
+ * 2026-09-17 07:00: five sends refused `native_thread_working` because Claude Desktop
809
+ * wrote its `away_summary` system row to the transcript 11 s earlier and the sub-agent
810
+ * that wrote it fired SubagentStop, so the newest event was not 'Stop' and the 30 s
811
+ * transcript window held. Every event that DOES mean a turn sets `turnOpen`
812
+ * (UserPromptSubmit, a tool) or `subagentsOpen` (SubagentStart), which the guards
813
+ * behind this set still read; a newer Stop replaces `stopAt`.
814
+ */
815
+ const AFTER_THE_TURN: ReadonlySet<string> = new Set(['Stop', 'SubagentStop', 'Notification'])
816
+
804
817
  export function withHookTurnClock(
805
818
  probes: OccupancyProbes,
806
819
  signalFor: (sessionId: string) => HookTurnSignal | undefined,
@@ -812,7 +825,7 @@ export function withHookTurnClock(
812
825
  if (provider !== 'claude') return null
813
826
  if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
814
827
  const signal = signalFor(threadId)
815
- if (!signal || signal.lastEvent !== 'Stop' || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
828
+ if (!signal || !AFTER_THE_TURN.has(signal.lastEvent) || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
816
829
  if (typeof signal.stopAt !== 'number') return null
817
830
  // The engine's own end of turn: the registry flipped idle AFTER this Stop, which
818
831
  // happens only once every Stop hook has returned. Anything else is strict.
@@ -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 : ''
@@ -69,6 +69,82 @@ let previewFailure: WhisperPreviewReason = null
69
69
  let previewWorkerModel: WhisperPreviewSidecarModel | null = null
70
70
  let warnedInvalidChoice = false
71
71
 
72
+ /**
73
+ * 6.50.2: a request that fails against a sidecar that is STILL RUNNING is a miss, not a
74
+ * death. Before this, one timeout (the Mac under load) or one refused fetch set
75
+ * `previewAvailable = false`, and its only writer back to true is the sidecar's own start,
76
+ * which runs once per server boot, so the meeting preview lane stayed silent for the rest
77
+ * of the process: 2026-09-17 16:00 to 2026-09-18 08:48 (34 h), 5,719 requests answered 204
78
+ * in under a millisecond while Turbo itself answered the same audio in 0.27 s.
79
+ *
80
+ * Now: a failure against a live sidecar starts a short cooldown and the next request after
81
+ * it tries again. PREVIEW_RECYCLE_AFTER_FAILURES in a row recycles the sidecar (stop, then
82
+ * start). A sidecar that is gone is restarted from the request path, backing off from
83
+ * PREVIEW_RESTART_BASE_MS to PREVIEW_RESTART_MAX_MS so a model that cannot load is not
84
+ * reloaded every minute. Any successful answer resets both counters.
85
+ */
86
+ export const PREVIEW_FAILURE_COOLDOWN_MS = 10_000
87
+ export const PREVIEW_RECYCLE_AFTER_FAILURES = 5
88
+ export const PREVIEW_RESTART_BASE_MS = 60_000
89
+ export const PREVIEW_RESTART_MAX_MS = 15 * 60_000
90
+ let previewCooldownUntil = 0
91
+ let previewConsecutiveFailures = 0
92
+ let previewLastRestartAt = 0
93
+ let previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
94
+ /** Set by the server's shutdown: no request may revive the sidecar after it (QA, 6.50.2). */
95
+ let previewShutdown = false
96
+
97
+ function previewSidecarAlive(): boolean {
98
+ const child = previewProcess
99
+ return !!child && child.exitCode === null && child.signalCode === null
100
+ }
101
+
102
+ function notePreviewRequestSuccess(): void {
103
+ previewConsecutiveFailures = 0
104
+ previewCooldownUntil = 0
105
+ previewRestartBackoffMs = PREVIEW_RESTART_BASE_MS
106
+ }
107
+
108
+ /** A request to the sidecar failed (not a preemption). Cooldown, recycle, or mark it gone. */
109
+ function notePreviewRequestFailure(error: unknown, label: string): void {
110
+ previewConsecutiveFailures++
111
+ const message = error instanceof Error ? error.message : String(error)
112
+ const at = new Date().toISOString()
113
+ if (previewSidecarAlive() && previewConsecutiveFailures < PREVIEW_RECYCLE_AFTER_FAILURES) {
114
+ previewCooldownUntil = Date.now() + PREVIEW_FAILURE_COOLDOWN_MS
115
+ console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); next try in ${PREVIEW_FAILURE_COOLDOWN_MS / 1000} s: ${message}`)
116
+ return
117
+ }
118
+ const alive = previewSidecarAlive()
119
+ previewAvailable = false
120
+ previewWorkerModel = null
121
+ previewFailure = 'preview_sidecar_unavailable'
122
+ console.warn(`[whisper-preview] ${at} ${label} failed (${previewConsecutiveFailures} in a row); ${alive ? 'recycling the sidecar' : 'the sidecar is gone'}: ${message}`)
123
+ maybeRevivePreviewSidecar()
124
+ }
125
+
126
+ /**
127
+ * Bring a failed sidecar back, from the request path. Only for a sidecar that ran and then
128
+ * failed or exited: a missing model, a missing binary or a foreign listener on the port
129
+ * cannot be fixed by a restart and stay as they are.
130
+ */
131
+ function maybeRevivePreviewSidecar(): void {
132
+ if (previewShutdown || previewAvailable || previewStarting) return
133
+ if (previewFailure !== 'preview_sidecar_unavailable' && previewFailure !== 'preview_start_failed') return
134
+ const now = Date.now()
135
+ if (previewLastRestartAt && now - previewLastRestartAt < previewRestartBackoffMs) return
136
+ if (previewLastRestartAt) previewRestartBackoffMs = Math.min(previewRestartBackoffMs * 2, PREVIEW_RESTART_MAX_MS)
137
+ previewLastRestartAt = now
138
+ previewConsecutiveFailures = 0
139
+ console.log(`[whisper-preview] ${new Date(now).toISOString()} restarting the preview sidecar (${previewFailure}); next restart no sooner than ${Math.round(previewRestartBackoffMs / 1000)} s`)
140
+ void (async () => {
141
+ if (previewProcess) await stopWhisperPreviewServer()
142
+ await startWhisperPreviewServer()
143
+ })().catch(error => {
144
+ console.warn(`[whisper-preview] restart failed: ${error instanceof Error ? error.message : error}`)
145
+ })
146
+ }
147
+
72
148
  interface ProcessEntry {
73
149
  pid: number
74
150
  ppid: number
@@ -260,6 +336,9 @@ async function endpointReady(path: '/health' | '/inference', init?: RequestInit,
260
336
  * uses Turbo while its canonical live worker remains Large-v3. Failure is
261
337
  * cosmetic: every recovery/finalization path stays untouched. */
262
338
  export async function startWhisperPreviewServer(): Promise<void> {
339
+ // A revive already past its stop when shutdown landed must not spawn an orphan that
340
+ // outlives the server.
341
+ if (previewShutdown) return
263
342
  const requested = requestedPreviewModel()
264
343
  const selected = selectedPreviewModel(requested)
265
344
  if (!needsPreviewSidecar(selected) || previewProcess || previewAvailable || previewStarting) return
@@ -359,7 +438,8 @@ function waitForPreviewClose(child: ChildProcess, timeoutMs: number): Promise<bo
359
438
  })
360
439
  }
361
440
 
362
- export async function stopWhisperPreviewServer(): Promise<void> {
441
+ export async function stopWhisperPreviewServer(options: { final?: boolean } = {}): Promise<void> {
442
+ if (options.final) previewShutdown = true
363
443
  const child = previewProcess
364
444
  previewProcess = null
365
445
  previewAvailable = false
@@ -395,27 +475,23 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
395
475
  model: 'small.en' | WhisperCommitModel
396
476
  backend: 'whisper-preview-server' | 'whisper-server'
397
477
  }> {
398
- if (previewAvailable && previewWorkerModel) {
478
+ if (!previewAvailable) maybeRevivePreviewSidecar()
479
+ if (previewAvailable && previewWorkerModel && Date.now() >= previewCooldownUntil) {
399
480
  const workerModel = previewWorkerModel
400
481
  const previewLease = tryAcquireMetalPreview()
401
482
  if (!previewLease) {
402
483
  return { text: '', model: workerModel, backend: 'whisper-preview-server' }
403
484
  }
404
485
  try {
405
- return {
406
- text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
407
- model: workerModel,
408
- backend: 'whisper-preview-server',
409
- }
486
+ const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
487
+ notePreviewRequestSuccess()
488
+ return { text, model: workerModel, backend: 'whisper-preview-server' }
410
489
  } catch (error) {
411
490
  if (previewLease.signal.aborted) {
412
491
  return { text: '', model: workerModel, backend: 'whisper-preview-server' }
413
492
  }
414
- const failedModel = previewWorkerModel
415
- previewAvailable = false
416
- previewWorkerModel = null
417
- previewFailure = 'preview_sidecar_unavailable'
418
- console.warn(`[whisper-preview] ${failedModel} preview failed; falling back to canonical worker: ${error instanceof Error ? error.message : error}`)
493
+ // Falls through to the canonical worker below for THIS request either way.
494
+ notePreviewRequestFailure(error, `${workerModel} preview`)
419
495
  } finally {
420
496
  previewLease.release()
421
497
  }
@@ -448,21 +524,20 @@ export async function transcribeWhisperMeetingPreview(audioBuffer: Buffer): Prom
448
524
  model: 'large-v3-turbo'
449
525
  backend: 'whisper-preview-server'
450
526
  } | null> {
527
+ if (!previewAvailable) maybeRevivePreviewSidecar()
451
528
  if (!previewAvailable || previewWorkerModel !== 'large-v3-turbo') return null
529
+ // A cooldown after a failed request is a quiet miss, never a latch (see above).
530
+ if (Date.now() < previewCooldownUntil) return null
452
531
  const previewLease = tryAcquireMetalPreview()
453
532
  if (!previewLease) return null
454
533
  try {
455
- return {
456
- text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
457
- model: 'large-v3-turbo',
458
- backend: 'whisper-preview-server',
459
- }
534
+ const text = await transcribeViaPreviewServer(audioBuffer, previewLease.signal)
535
+ notePreviewRequestSuccess()
536
+ return { text, model: 'large-v3-turbo', backend: 'whisper-preview-server' }
460
537
  } catch (error) {
461
538
  if (previewLease.signal.aborted) return null
462
- previewAvailable = false
463
- previewWorkerModel = null
464
- previewFailure = 'preview_sidecar_unavailable'
465
- console.warn(`[whisper-preview] meeting Turbo preview failed; canonical meeting ASR was not affected: ${error instanceof Error ? error.message : error}`)
539
+ // Canonical meeting ASR is not affected by any of this: the preview is cosmetic.
540
+ notePreviewRequestFailure(error, 'meeting Turbo preview')
466
541
  return null
467
542
  } finally {
468
543
  previewLease.release()
@@ -61,7 +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
+ import { parseTurnsParam, readRecentSessionTurns, type RecentTurnsRead } from '../lib/agent-session-turns.js'
65
65
  import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
66
66
 
67
67
  export const agentSessionsRouter = Router()
@@ -479,7 +479,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
479
479
  // when asked, so the detail poll and every older client get the same bytes as before.
480
480
  // A failure here costs the history, never the detail page.
481
481
  const turnsWanted = parseTurnsParam(req.query.turns)
482
- let recentTurns: Awaited<ReturnType<typeof readRecentSessionTurns>> | null = null
482
+ let recentTurns: RecentTurnsRead | null = null
483
483
  if (turnsWanted !== null) {
484
484
  try {
485
485
  recentTurns = await readRecentSessionTurns(provider, found, turnsWanted)