@gotcos/glasses-server 6.95.0 → 6.98.0

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +453 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +570 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,136 @@
1
+ ## 6.98.0 - Live cues with five kinds, phone tuning, and a meeting assistant that reads links (public)
2
+
3
+ 2026-11-03. The public release of the private 6.95.0 to 6.97.9 line, merged with everything 6.94.4 published. Pairs with COS Glasses 6.11.669 and works with COS Control 0.5.363.
4
+
5
+ - Live cues stay off until a person turns them on. Once on: G2 meetings armed by the phone get all five kinds (reinforce, challenge, decision, promise, follow-up), cues are paced across the meeting instead of spending the cap early, a cue that repeats a recent opening line is held back, and names come from the meeting's own speakers.
6
+ - For anyone who already has live cues on, pacing, the repeat hold, meeting speaker names sent to the cue writer, and grouped open items on Mac meetings start on install. Each has an env switch to turn it off.
7
+ - Cue tuning from the phone and from COS Control uses `GET/POST /api/live-cues/v2/settings?v=3`.
8
+ - The meeting assistant reads a public link typed in a question, runs up to three searches for a comparison or fact check and leads with a verdict, and treats "private" as a privacy word only when it means privacy. Web searches go out only with Public topics on, using only the words the user typed. A link the user types into a question is read even with Public topics off, unless the question says not to use the web. Pasted text over 400 characters and pages read from typed links are kept as meeting files for live cues.
9
+ - Dropped `.xlsx` files give their text to the meeting assistant.
10
+ - A daily web lookup count (`webToday`) on the cue settings and meeting assistant config.
11
+ - Saved Mac calls no longer fail on one silent window, and a meeting that has stopped retrying no longer locks Restart and Update.
12
+ - The token ledger records provider-reported usage where the provider gives it.
13
+ - Opt-in only, unchanged by default: speaker merge shadow, general cues, grounded cues, the Tableau data arm (needs a local `data-facts.json`).
14
+
15
+ ## 6.97.9 - G2 meetings with all five cue kinds stay focused (private candidate)
16
+
17
+ 2026-11-02. Built on 6.97.8. Pairs with the phone build that arms G2 meetings with all five kinds. Private; npm latest stays 6.94.4.
18
+
19
+ - A `meeting_` session armed with `kinds: 'extended'` gets the five cue kinds and writer v4, and nothing else of the Mac's: it is never offered to the general (web) cue runner and never gets an insight, cue mode `general` never switches its focused cues off, and the open-items fold never holds its cues (they show as written). Those three, and the insight emit path, now key on the session being a Mac recording (`mac_` id), not on the kind set.
20
+
21
+ ## 6.97.8 - Live cues: Tableau facts, shared links and pacing (private candidate)
22
+
23
+ 2026-11-02. Built on 6.97.7, which it carries. Pairs with COS Control 0.5.365. Private, for one Mac; npm latest stays 6.94.4.
24
+
25
+ - Grounded cues gain a data arm: a local `data-facts.json` (written by COS from the Tableau report, contract v1) offers one candidate when the recent speech names a Quilt industry, brand or SBU/XBU and a funnel word or figure. The cue states the period, marketing-sourced scope and refresh time, and never presents it as revenue, MRR or locations. Quilt meetings only: a meeting with no confirmed domain needs a brand or SBU/XBU named, a Quilt-domain memory in the same evaluation, or a Quilt term in the calendar title; another confirmed domain or an unreadable context is off. Off when the file is missing, invalid or ahead of the clock; an open month expires 120 hours after its refresh, a closed month after 35 days. Sent as a `file` source with no locator, so older clients render it. Trace: `arms.data`, `dataArm`, `dataSignal`, `dataAgeH`; the cue log marks it `dataNote`.
26
+ - The meeting assistant reads the same facts for funnel, budget, pacing or "check Tableau" questions, from the user's own typed words only. No network.
27
+ - A public link typed in a meeting question is read as a page even without "search" wording (up to two; credentials, keys, private, NAT64, IPv4-compatible, site-local, multicast, 6to4 and Teredo addresses refused). Pasted text over 400 characters and pages read from typed links are kept as meeting files for live cues, after the user's own files, with unsafe links cut, and never used as transcript-cleanup vocabulary.
28
+ - "Private" counts as a privacy word only when it means privacy (private notes, data, conversation, pricing, "keep this private", "in private"); private aviation, jets, equity or schools are public subjects again in the web gate, the public topic path, fact checks and general cues.
29
+ - Public comparison and fact-check questions get up to three parallel searches on the user's own words (plus "average" or "comparison") and four page reads, a 55 s deadline (others keep 30 s) with the second plan hop, public topic call and claim planner skipped, and an answer that leads with a verdict naming who was right, then cited figures and one caveat. When no page could be read, one line starting "Not checked against a source:" gives the general-knowledge answer (never after a privacy withhold).
30
+ - `.xlsx` text extraction on the media route: linear scanners, bounded by 2 s of scanning, 48 MB inflated, 12 sheets and 200 rows per sheet; no formulas.
31
+ - New daily web lookup ledger, `webToday` on `GET /live-cues/v2/settings?v=3` and `GET /meeting-ask/config`: searches, pages read and pages failed for cues, the assistant and fact checks, and lookups held back after the trigger, by reason. `general.today` keeps its meaning.
32
+ - Cue pacing: the per-meeting cap opens with half the cap, then refills across the meeting (calendar length when known, else 60 minutes; 5 minutes per cue at cap 25). Caps of 4 or less are not paced; a raised cap takes effect at once (`COS_LIVE_CUES_V2_PACING=0` turns it off).
33
+ - On Mac recordings, "no owner or date" cues the writer marks `open` fold into one cue per 10 minutes headed by the first item; an item left alone 20 minutes is shown on its own when the meeting continues, unless the room talked about it since; every held cue is kept in the cue log marked `held` and left out of cue history, counts and ratings. G2 cues are shown as written (`COS_LIVE_CUES_V2_OPEN_ITEMS=0`).
34
+ - A cue whose opening line repeats one of the last six is withheld (`COS_LIVE_CUES_V2_LEAD_REPEAT=0`). The writer gets the meeting's known names for spelling: this meeting's speakers, the owner and name-like vocabulary; enrolled voice names are never sent (`COS_LIVE_CUES_V2_KNOWN_NAMES=0`).
35
+
36
+ ## 6.97.7 - Live cues: grounded cues, cue modes and general cues (private candidate)
37
+
38
+ 2026-11-01. Built on 6.97.6 (SubQ session find), which it carries. Pairs with COS Control 0.5.364. Everything new is off by default: until the owner changes the cue mode or turns grounded cues on, this server answers, asks and writes exactly as 6.97.6 does.
39
+
40
+ - `GET /api/live-cues/v2/settings?v=3` and `POST {v: 3, ...}` add the cue mode (`focused`, `general`, `all`; default focused), grounded cues (`classic`, `grounded`; default classic) and six general-cue limits to the v2 body. `?v=2`, `{v: 2}` and the unversioned calls answer byte for byte as before; an older server answers `?v=3` with 400 `unsupported_version`, which is Control's signal to hide the new controls. The values live in `live-cues-v2-settings.json` (schema 3). `COS_LIVE_CUES_MODE=focused` and `COS_LIVE_CUES_GROUNDING=classic` can only force the safe value; any other value defers to the setting.
41
+ - Grounded cues: cue_memory.py and the owner's notes run together, the files attached to the meeting add their best matching passage, copies of this conversation and personal notes in a work meeting are dropped, one meeting is one candidate, and one judge request asks yes or no per candidate (`COS_LIVE_CUES_V2_JUDGE_YES`, default 0.65, the best two by yes are kept). The writer is told the confirmed calendar title. The trace gets the judge's probabilities, the kept ids (`keptIds`; `kept` stays a count), arm timings, an excluded count and a count of notes that named no domain, never text. A copy of this conversation is recognised against everything said since the meeting was armed (up to 50,000 6-word sequences), not only the last 400 words. With classic nothing in an evaluation changes.
42
+ - A cue source may carry its kind (`meeting`, `memory`, `file`, `web`) and one locator (`recordId`, `memoryId`, `fileId`, `url`, https only). The cue log accepts them, the kind `insight`, and the optional `arms` and `general` fields; `memorySource` keeps its three values. `shared/live-cues-v2.ts` changed, so the companion's byte-identical copy is stale until a companion release takes it; the companion is never sent an insight or a source it cannot read.
43
+ - General cues (cue mode `general` or `all`, Mac meetings only; G2 meetings keep focused cues and never get an insight; `general` turns focused cues off for Mac meetings). They need Ask's Public topics on. A server-wide runner, separate from focused cues, ticks each Mac meeting every `intervalSeconds` (default 300) once enough new speech arrived. Order: the meeting must not be confirmed personal, an orientation that cannot be read withholds, the window must not be about health, family, money, legal or school matters, and voice names must be readable; then one Jev or SubQ yes/no on whether the room is on a public topic (`trigger`, default 0.6), then a pinned Haiku call picks one short topic, then admission refuses any speaker, enrolled voice or calendar-title name, any digit or number word, any owner vocabulary term (also written without spaces), deny-list words, non-Latin script and repeats; a local topic gets the owner's city-level home area and is admitted again; then one web search (`auditCaller: live_cues_general`), then a writer whose numbers must appear in a cited source. Only the topic reaches the web, never the transcript. The cue is kind `insight`, sent as `coaching_nudge_v2` with no v1 twin, logged with `general: {category, query}`, and never touches focused cooldown, counters or breaker.
44
+ - General caps, durable and charged before each call: attempts and cues per meeting, topic calls, web searches and (env-only, default 300) trigger calls per local day; a breaker after 3 failures opens for an hour. The trace row `general_tick` holds numbers, a category and, only for non-privacy refusals, a query hash; never the query. `GET /api/live-cues/status?sessionId=` adds `general {state, reason, cues, attempts}` for Mac meetings.
45
+ - The ASR route passes each chunk's speakers to live cues (stored for the general name gate only). `end().nudgesGenerated` counts focused cues only. The topic module reads the profile lazily (a test that mocked the profile partially failed at import before).
46
+
47
+ ## 6.97.6 - SubQ session find shows every session that fits (private candidate)
48
+
49
+ 2026-11-01. Carries what 6.94.4 publishes, and replaces the measuring build 6.97.5. Pairs with COS Control 0.5.363.
50
+
51
+ ## 6.97.4 - Last review fixes (private candidate)
52
+
53
+ 2026-11-01. A private candidate for one Mac, carrying the fixes published in 6.94.3. Pairs with COS Control 0.5.363.
54
+
55
+ - Merge checks every copy of a name stored in both Unicode forms and refuses on any below the bar; delete previews count them all; renames reach meetings in either form. `?name=a&name=b` answers 400.
56
+ - A file reference like `meeting.ts:2452`, bare `localhost` and a bare IP are no longer treated as links.
57
+
58
+ ## 6.97.3 - Speaker names and session find, from QA (private candidate)
59
+
60
+ 2026-11-01. A private candidate for one Mac. Pairs with COS Control 0.5.363.
61
+
62
+ - Accented names: merge, delete-person, calibration, the rename fan-out and the embeddings manager now match a stored name in either Unicode form, a meeting record id matches in either form (default-on), and a confirm on a decomposed name records the person's confirmation. Merge refuses if any stored copy of a name is below the similarity floor, and the delete preview counts every copy. `?name=a&name=b` on enroll is a 400.
63
+ - Session find: a title or prompt cut inside an attachment is dropped instead of sent, Windows, volume and escaped-space home paths are masked, ordinary words like `/home/dashboard` stay, and links to an IP address or localhost with a port or path are masked. Only home-folder paths are masked, not every file path; bare domains, bare addresses and `file.ts:12` stay. Refusals log one line per reason, with no words.
64
+
65
+ ## 6.97.2 - Local paths stay off the search (private candidate)
66
+
67
+ 2026-11-01. A private candidate for one Mac. Pairs with COS Control 0.5.363.
68
+
69
+ - Session find drops attachment paths at the start of a first prompt, so the 150 characters sent begin at the typed words, and it masks home paths, `~/`, `file://` and `C:\Users` in text, titles and queries. On this Mac 55 of the newest 508 first prompts started with an attachment and 105 held a home path in their first 400 characters.
70
+
71
+ ## 6.97.1 - Search sessions by meaning (private candidate)
72
+
73
+ 2026-11-01. A private candidate for one Mac, built on 6.97.0, carrying the same commits as the public line's session find route. Pairs with COS Control 0.5.362.
74
+
75
+ - `POST /api/agent-sessions/find` asks the person's decision model (Jev or SubQ) which recent sessions a few typed words refer to, after consent for that provider, and returns up to 5 rows in order of likelihood.
76
+ - `GET /api/decision-provider` adds `capabilities.sessionFind`. The session list and search are unchanged.
77
+ - Accented speaker names now match. COS Control's helper receives names in decomposed Unicode, so "Yes, this is Brigitta Pólya" failed with "No chunk in this meeting is labelled" although her chunks carried that name. Speaker-name routes in meeting and voice now read names as NFC and compare NFC on both sides, and a new name is stored as NFC. Names already saved are not rewritten.
78
+
79
+ ## 6.97.0 - COS's token ledger records what providers report, and a stopped meeting no longer locks Update (private candidate)
80
+
81
+ 2026-10-31. A private candidate for one Mac, built on 6.96.4. Pairs with the COS cost view in the next COS Control build, which reads the ledger file directly; there is no new endpoint.
82
+
83
+ **Restart and Update are no longer locked by a meeting that has stopped trying.** In 6.96.4 a saved meeting waiting on HQ held `meeting_sync.active`, `meeting_sync.blocksRestart` and `finalization.pending` for as long as its job existed, up to 30 days, though retries in one process end after three attempts. COS Control disabled Restart, Update Server and Sync now on those, so a fix could not reach the Mac.
84
+ - A job that has used its retries now records that it stopped, in its own job file, and counts in none of those three. It stays visible as a `meeting_sync.meetings` row with phase `stopped` and a label that says so. `finalization` keeps its shape, and an older COS Control unlocks too, because `pending` reads 0. Its audio and job file stay. Nothing is deleted.
85
+ - A job that is working still blocks: while an attempt runs, and between its retries.
86
+ - A later start does not take the lock back every time. A stopped job is tried once, with no retry timers, on a start of a different server version or a start 6 hours or more after it stopped. Its lock lasts as long as that one attempt runs, and it is the same window as the maintenance lease. Any other start runs nothing and holds nothing.
87
+ - A job that failed before this version recorded stops is tried once at its first start on 6.97.0, then stops the same way. A job the boot gate parked for crash-looping also stops holding the lock; it keeps the gate's own second chance and Retry.
88
+
89
+ **The ledger.**
90
+ - A token-audit row now carries real usage when the provider reported it: `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `cache_write_1h_tokens`, `cost_usd` and `resolved_model`, with `usage_kind` `provider_final`. That label now needs real numbers. All four counts are always written. `cache_write_1h_tokens` is part of `cache_write_tokens`; it and `cost_usd` appear only when the provider states them.
91
+ - A row with none of these keeps every existing field byte for byte, so the Python readers are unaffected. Polish rows keep `input_chars` as the words dictated and add `prompt_chars`: what the model is sent, system prompt included.
92
+ - What the numbers mean differs by provider. `input_tokens` never includes cache reads. It excludes cache writes only on Claude, the one provider that reports them. Codex's write field read 0 on all 31,670 rollout rows checked, and the direct lane reads none, so on both `cache_write_tokens` is 0 and any write stays in `input_tokens`. `resolved_model` is what the CLI says ran on Claude, and what COS asked for on Codex, direct and Ollama.
93
+ - Real where a provider states it: the lens gist, cues and transcript cleanup; Meeting Ask; warm dictation polish; the Claude, Codex and direct bridges and the boot pre-warm; Claude attached and fork runs.
94
+ - Still estimated: cold polish, whose plain text output has no usage (JSON there would add failures that feed the breaker); every Cursor path, whose cache parser has never produced a real row; Codex attached and fork runs, which share a thread with the desktop; and summaries that run through `spawnClaudeText`.
95
+ - A resumed turn is charged only itself. Claude's `total_cost_usd` and `modelUsage` and Codex's `usage` are running totals that carry earlier turns of the session. Claude reads `model_usage_since_last_result`, and each result counts once however many a steered run writes. Usage on a result that arrives after the row is written gets one `:late_result` row under the same run id. On a CLI without that field, a resumed Claude run reports this turn's tokens and no cost.
96
+ - Codex charges the difference from the last total this bridge saw on the thread, and only when it can show nothing else ran there since. The row stays estimated on the first resumed turn after a restart, after a turn that never reported a total, after an attached run, and when the thread's rollout grew between turns, which is how a desktop run shows. The turn after is real again.
97
+ - Every row these paths log now names its run and provider session where known: `run_id` and `cli_session_id`. A provider id that has spaces, or is shaped like a key or a path, is refused rather than written.
98
+ - Dispatched tasks and meeting reviews get their own caller, `dispatch_task` and `work_review`, in place of `full_query`. Attached and fork runs that reach an end get a row for the first time: source `g2-attached`, caller `attached_turn`; source `g2-fork`, caller `fork_thread`.
99
+ - A missing or malformed usage block changes nothing: same answer, no failure, no breaker count, no retry, and the row stays estimated. No prompt or answer text enters the audit.
100
+ - The test config now points the audit at a scratch file (`COS_TOKEN_AUDIT_FILE`), so a test's made-up numbers cannot reach a real ledger. Tests that did not mock the audit used to append to the home file.
101
+
102
+ ## 6.96.4 - Saved Mac calls no longer stick on one empty window (private candidate)
103
+
104
+ 2026-10-31. A private candidate for one Mac, built on 6.96.3 with nothing else in it.
105
+
106
+ - A saved Mac or iPhone call no longer fails because Parakeet heard no words in one window over audio the speech detector counts as speech. The window is treated as an answer: it keeps any live words it had, stays empty if it had none, and says so in that window's `stt.fallbackReason`. Before, that one window failed the whole call, and every retry failed the same way.
107
+ - A real Parakeet failure (a CLI exit, a timeout, a busy lane) still blocks the call and retries, as it did.
108
+ - An error that never reached the CLI is no longer reported as `cli_failed`. It now reads `internal`, and the log names the window and the reason.
109
+ - Cleanup no longer deletes the pending audio of a saved meeting still waiting on HQ, for up to 30 days. Before, it went 12 hours after the last attempt, and the next start finished the meeting on its streaming text.
110
+
111
+ ## 6.96.3 - Fewer voices in Mac speaker review, and suggested names on request (private candidate)
112
+
113
+ 2026-10-30. A private candidate for one Mac. Pairs with the matching private COS Control build.
114
+
115
+ - Speaker review can show a Mac recording's voices as merged groups first, then a row for each voice with 15 seconds or more, then one expandable row for the shorter ones. It reads the merges speaker shadow already logged for that meeting. Display only: no label, name, sample or file changes, and every voice's words stay one click away. A voice named since the log was written leaves its group. A Mac with speaker shadow off sees no change.
116
+ - A new on-click job compares each of a meeting's voices with the shared voice profiles, one at a time, using the rule frozen in the October 30 study (top score .70 or more, margin .05 or more). Voices in a merged group share one suggestion only when each passed on its own for the same person. On the 12 voices judged by ear it reproduced the study: 10 right, 0 wrong, 2 with no suggestion. That is three people, not proof across every profile, and suggestions on other voices are unverified. A result expires when the meeting or the voice profiles change.
117
+ - A suggestion is never a name. Those scoring .80 or more with a .05 margin are marked high confidence, and they are still suggestions. A person confirms them, one at a time or as a previewed batch, through the same writes as naming a voice by hand. The job writes nothing, keeps its result in memory only, never suggests the owner, ignores samples taken from the same recording, and refuses during a recording or maintenance.
118
+ - A batch stops at the first refusal and reports what was named. Undo reverses the whole batch and removes only the voice samples it added, so names set one at a time keep theirs. A voice a person changed afterward is left alone and reported. A batch never names the owner.
119
+ - Automatic names are unchanged.
120
+
121
+ ## 6.96.2 - Source-bound anonymous speaker review (private local candidate)
122
+
123
+ 2026-10-30. Pairs with private COS Control 0.5.357 / build 411 for the interval review. Existing clients retain their compatible review path.
124
+
125
+ - A separately qualified, source-bound whole-call packet can project anonymous groups onto fully supported timed words. Gaps, transitions and unsupported speech remain unresolved. This derived review does not rewrite the canonical meeting, correction ledger, names, profiles, words or timestamps.
126
+ - The review separates supported anonymous groups, existing named rows and unresolved speech. These rows are not a count of people. Fragment counts and old root-level phrases do not stand in for interval evidence.
127
+ - Human review supports exact timed-word naming, renaming and Undo while the automatic producer is off. Each action is bound to the reviewed source revision; prior corrections and protected words take precedence. For this private candidate, a name already asserted elsewhere in the source is refused rather than risking attribution of unrelated words.
128
+ - A recording can return to its original review after its interval naming actions are undone. Interrupted transactions require recovery before another action; packet and state changes invalidate stale review tokens. Original files, earlier corrections and voice profiles remain untouched.
129
+ - Held-naming Undo proceeds separately for each meeting: a call-wide review can block its own meeting without preventing unrelated meetings in the batch from reverting.
130
+ - The accepted-only October 30 repair covers 3,423 previously unassigned timed words in four anonymous groups, leaves 9,471 words unresolved, and preserves 253 named words. Whole-call human comparison checks on two earlier calls found no conflicting names within this narrower accepted support; identity accuracy on the current call remains unconfirmed. Broader interval expansion failed that quality gate and remains excluded.
131
+ - Includes the 6.96.1 bounded save-receipt lookup correction for large recording sidecars; retained audio and saved-meeting receipts remain separate from speaker quality.
132
+ - Transcript cleanup, live cues and live speaker ID remain disabled. Automatic interval production, profile naming and enrollment are not enabled by this candidate. Historical repair requires the exact qualified packet and correction-preservation checks.
133
+
1
134
  ## 6.95.0 - Speaker merge shadow mode (opt-in, log only)
2
135
 
3
136
  2026-10-28. Compatible with COS Control 0.5.352; no Control or glasses change required.
@@ -12,6 +145,30 @@
12
145
  - **Known limits:** a recording's first run with a cold CoreML cache leaves up to four empty `NSIRD_fluidaudiocli_*` folders in the per-user temp dir (counted in the record, not removed). Recovered (orphan) Mac captures are not queued. The gates were fixed on one truth-labeled call; scoring the logged proposals is the next step.
13
146
  - **Switches:** the per-Mac setting and `COS_SPEAKER_SHADOW` (default off), `COS_SPEAKER_SHADOW_DAILY_RUNS`, `COS_SPEAKER_SHADOW_TIMEOUT_MS` (may only lower 180 s), `COS_SPEAKER_SHADOW_RSS_MB`, `COS_SPEAKER_SHADOW_MODELS`. No default-on change.
14
147
 
148
+ ## 6.94.4 - SubQ session find shows every session that fits
149
+
150
+ 2026-11-01. Pairs with COS Control 0.5.363. Only affects people who chose SubQ and said yes to find sessions by meaning. Jev and everything else are unchanged.
151
+
152
+ - SubQ now scores each likely session on its own, as Jev does. Before, its scores across all sessions added up to one against a floor of 0.5, so usually one row could show and others that fit were hidden.
153
+ - Measured through the real route on 26 of 27 queries: the right session was shown in 19 of 20 and first in 18. An answer 51 days old is now found. Of 6 no-answer queries, 5 say nothing close and 1 shows weak rows marked possible. Wrong rows can still score high below the right one.
154
+ - A SubQ search now uses about 10,500 to 24,000 tokens against its 3M daily cap.
155
+
156
+ ## 6.94.3 - Search sessions by meaning, and accented speaker names
157
+
158
+ 2026-11-01. Pairs with COS Control 0.5.363. Control 0.5.361 and older keep working and never call the new route.
159
+
160
+ **New, and off until you say yes in Control.**
161
+
162
+ - `POST /api/agent-sessions/find` asks your decision model (Jev by default, SubQ if chosen) which recent sessions a few typed words refer to, and returns up to 5 rows in order of likelihood. It answers only for the provider you consented to in Control, and refuses before reading any session otherwise.
163
+ - Each search sends the typed words, plus the title and first 150 characters of the first prompt from up to 254 recent sessions, then up to 508 if the first pass finds nothing strong. Emails, URLs, links to an IP address or localhost, home-folder paths (not every file path) and key-shaped text are removed first. Names and other details are not. SubQ keeps what it is sent. Archived, keyed and scheduled sessions are left out.
164
+ - Its own daily caps (3M tokens per provider), breaker and `COS_SESSION_FIND=0` switch. `GET /api/decision-provider` adds `capabilities.sessionFind`.
165
+
166
+ **Default-on, no setting.**
167
+
168
+ - Accented speaker names now match in either Unicode form. "Yes, this is Brigitta Pólya" failed with "No chunk in this meeting is labelled" because Control's helper hands names over decomposed. Meeting and voice routes read names as NFC, compare both sides, and store new names as NFC. Merge, delete-person, calibration, the rename fan-out and the embeddings manager match a stored name in either form, and a meeting record id matches in either form too (a decomposed id from Control's helper opens the same record). Merge refuses if any stored copy of a name is below the similarity floor. Names already saved are not rewritten.
169
+
170
+ No other route changes. The session list and search are unchanged.
171
+
15
172
  ## 6.94.2 - Scoped profile matches for saved Mac recordings
16
173
 
17
174
  2026-10-28. Compatible with COS Control 0.5.352; no Control or glasses package change required.
package/README.md CHANGED
@@ -699,6 +699,10 @@ flag a Mac rename is text only. `GET /api/health` reports `capabilities.macLiveV
699
699
  The saved meeting is relabeled by the whole-meeting pass. Live numbers are kept;
700
700
  voices the live path did not prove are numbered above them at save.
701
701
 
702
+ ### Session find by meaning (6.94.3)
703
+
704
+ `POST /api/agent-sessions/find` backs ⌘K in COS Control 0.5.363. It is off until the person says yes for one provider in Control. See `docs/decision-providers.md` for what leaves the Mac, the caps and `COS_SESSION_FIND=0`.
705
+
702
706
  ### Saved Mac profile reconciliation (6.94.2)
703
707
 
704
708
  After transcription and diarization, eligible Mac recordings receive a bounded
@@ -36,6 +36,19 @@
36
36
  "COS_LIVE_CUES_V2_MAX_PER_MEETING",
37
37
  "COS_LIVE_CUES_V2_DAILY_WRITER_CALLS",
38
38
  "COS_LIVE_CUES_V2_MAC",
39
+ "COS_LIVE_CUES_V2_JUDGE_YES",
40
+ "COS_LIVE_CUES_V2_LEAD_REPEAT",
41
+ "COS_LIVE_CUES_V2_KNOWN_NAMES",
42
+ "COS_LIVE_CUES_V2_PACING",
43
+ "COS_LIVE_CUES_V2_OPEN_ITEMS",
44
+ "COS_LIVE_CUES_MODE",
45
+ "COS_LIVE_CUES_GROUNDING",
46
+ "COS_LIVE_CUES_GENERAL_INTERVAL_S",
47
+ "COS_LIVE_CUES_GENERAL_TRIGGER",
48
+ "COS_LIVE_CUES_GENERAL_ATTEMPTS",
49
+ "COS_LIVE_CUES_GENERAL_MAX_PER_MEETING",
50
+ "COS_LIVE_CUES_GENERAL_DAILY_TOPIC_CALLS",
51
+ "COS_LIVE_CUES_GENERAL_DAILY_WEB_SEARCHES",
39
52
  "COS_TAILSCALE_NUDGE",
40
53
  "COS_MAC_VOICE_LABELS",
41
54
  "COS_MAC_LIVE_VOICES",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.95.0",
3
+ "version": "6.98.0",
4
4
  "description": "COS Glasses: 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": {
@@ -52,6 +52,7 @@
52
52
  "!server/scripts/work-intake-mutation-gate.mjs",
53
53
  "!server/scripts/work-progress-mutation-gate.mjs",
54
54
  "!server/scripts/work-search-mutation-gate.mjs",
55
+ "!server/scripts/session-find-redact-mutation-gate.mjs",
55
56
  "!server/scripts/fact-check-mutation-gate.mjs",
56
57
  "!server/scripts/stale-session-mutation-gate.mjs",
57
58
  "!server/scripts/autonomous-save-mutation-gate.mjs",
@@ -77,6 +78,7 @@
77
78
  "!server/scripts/transcript-cleanup-mutation-gate.mjs",
78
79
  "!server/scripts/live-cues-v2-notes-mutation-gate.mjs",
79
80
  "!server/scripts/live-cues-verdicts-mutation-gate.mjs",
81
+ "!server/scripts/live-cues-general-mutation-gate.mjs",
80
82
  "!server/scripts/key-lanes-p1-mutation-gate.mjs",
81
83
  "!server/scripts/key-lanes-p2-mutation-gate.mjs",
82
84
  "!server/scripts/key-lanes-p3-mutation-gate.mjs",
package/server/index.ts CHANGED
@@ -75,6 +75,7 @@ import { nativeHead, realNativeHeadDeps, registerClaudeTranscriptHint } from './
75
75
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
76
76
  import { displayRouter } from './routes/display.js'
77
77
  import { transcribeStreamRouter, getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './routes/transcribe-stream.js'
78
+ import { installCodexRolloutProbe } from './lib/codex-rollout-watermark.js'
78
79
  import { meetingRouter, resumeAutonomousMeetingSaves, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
79
80
  import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
80
81
  import { meetingsRouter } from './routes/meetings.js'
@@ -161,12 +162,15 @@ import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.
161
162
  import { createSubqKeyRouter } from './routes/subq-key.js'
162
163
  import { createDecisionProviderRouter } from './routes/decision-provider.js'
163
164
  import { createJevRouter } from './routes/jev.js'
165
+ import { createSessionFindRouter } from './routes/session-find.js'
166
+ import { isSessionFindPost } from './lib/session-find.js'
164
167
  import { createWorkChatRouter } from './routes/work-chat.js'
165
168
  import { createWorkOrchestratorRouter } from './routes/work-orchestrator.js'
166
169
  import { createWorkLensRoundsRouter } from './routes/work-lens-rounds.js'
167
170
  import { startWorkOrchestrator } from './lib/work-orchestrator/runtime.js'
168
171
  import { createWorkSearchRouter } from './routes/work-search.js'
169
172
  import { isReadOnlyApiPost } from './lib/work-search.js'
173
+ import { isMacVoiceNamingBatchPost } from './lib/mac-voice-naming-batch.js'
170
174
  import { dataPath } from './lib/data-dir.js'
171
175
  import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
172
176
  import { BOOT_RESUME_LEDGER_FILE, createBootResumeGate, installBootResumeGate } from './lib/boot-resume.js'
@@ -396,6 +400,8 @@ app.use('/api', (req, res, next) => {
396
400
  // 6.65.0: a POST that only reads (Work search: a board read and one Jev question) is not a mutation. See
397
401
  // lib/work-search.ts isReadOnlyApiPost for why holding the lease would hurt and why skipping it is safe.
398
402
  if (isReadOnlyApiPost(req.method, req.path)) return next()
403
+ // Session find (Control 0.5.363) reads the session store and asks a model; it writes only its own token ledger. See lib/session-find.ts.
404
+ if (isSessionFindPost(req.method, req.path)) return next()
399
405
  // V2 video original chunks are bounded to the advertised session size (1 MiB
400
406
  // on new sessions, 256 KiB on leftover drafts) and commit through the upload
401
407
  // registry's own generation lock. Holding the global mutation lease while
@@ -442,6 +448,8 @@ app.use('/api', (req, res, next) => {
442
448
  || req.path.startsWith('/maintenance/drain')
443
449
  // 6.83.0 lens plans: each write takes the api_mutation lease itself and answers the contract's 503 `maintenance`.
444
450
  || req.path.startsWith('/work/chat/lens-')
451
+ // 6.96.3 voice naming batches: each write holds api_mutation itself; a drain gets the route's own refusal.
452
+ || isMacVoiceNamingBatchPost(req.path)
445
453
  if (lifecycleOwned) return next()
446
454
 
447
455
  // COS Control must prove the candidate while a committed cross-boot gate is
@@ -858,6 +866,8 @@ app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workRe
858
866
  app.use('/api', createDecisionProviderRouter())
859
867
  app.use('/api', createSubqKeyRouter())
860
868
  app.use('/api', createWorkSearchRouter())
869
+ // Find a session by meaning (Control 0.5.363): consent per provider, its own ledgers and breakers (lib/session-find.ts).
870
+ app.use('/api', createSessionFindRouter())
861
871
  app.use('/api', createWorkChatRouter())
862
872
  // 6.83.0 Work orchestrator: Control's Work snapshot, the planner input and plan usage (docs/work-orchestrator.md).
863
873
  app.use('/api', createWorkOrchestratorRouter())
@@ -1507,6 +1517,8 @@ listenRequiredServers(listeners).then(() => {
1507
1517
  if (admittedRuntimeStarted) return
1508
1518
  admittedRuntimeStarted = true
1509
1519
 
1520
+ // 6.97.0: lets the Codex bridge show nothing else ran on a thread between its turns.
1521
+ installCodexRolloutProbe()
1510
1522
  // 6.78.0: before any boot resume. Was the last exit clean? Only boots after a crash count.
1511
1523
  bootResume.openBoot()
1512
1524
  // Resume already-saved meetings whose post-response HQ/operations work
@@ -106,6 +106,8 @@ import { cursorIsolationUnavailableError, cursorSpawnEnv, isCursorIsolationUnava
106
106
  import type { PermissionAllowance } from './banned-permission-args.js'
107
107
  import { anyKeyedSessions, isKeyedSession } from './keyed-sessions.js'
108
108
  import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
109
+ import { forgetCodexThreadTotals, SideRunTap } from './provider-usage.js'
110
+ import { logTokenAudit, type TokenAuditEntry } from './token-audit.js'
109
111
  import {
110
112
  continueAllowanceFor,
111
113
  validateContinuePlan,
@@ -333,6 +335,8 @@ export interface AttachedTurnDeps {
333
335
  * would not have performed.
334
336
  */
335
337
  preflight: () => AttachedPreflightVerdict
338
+ /** 6.97.0: where the run's audit row goes. Only production wiring sets it, so no test writes a real ledger. */
339
+ auditRun?: (entry: TokenAuditEntry) => void
336
340
  resolveBinary: (provider: AttachedProvider) => BinaryResolution
337
341
  spawn: (request: AttachedSpawnRequest) => AttachedChildProcess
338
342
  /** MUST be the measured kernel start. See the header. */
@@ -961,6 +965,8 @@ async function run(
961
965
  if (request.abortSignal?.aborted === true) {
962
966
  return fail('cancelled', 'not_attempted', { ...base, detail: 'before_spawn', reaped: true, durationMs: duration() })
963
967
  }
968
+ // 6.97.0: this run moves the thread's running total without the bridge seeing it.
969
+ if (provider === 'codex') forgetCodexThreadTotals(nativeThreadId)
964
970
  let child: AttachedChildProcess
965
971
  try {
966
972
  child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv(), provider })
@@ -1013,8 +1019,14 @@ async function run(
1013
1019
  })
1014
1020
  }
1015
1021
 
1022
+ // 6.97.0: a passive reader, wired after the run's own listeners, for one audit row at the end.
1023
+ const usageTap = deps.auditRun ? new SideRunTap(provider) : null
1016
1024
  try {
1017
- return await driveChild({ child, pid, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt, abortSignal: request.abortSignal })
1025
+ const running = driveChild({ child, pid, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt, abortSignal: request.abortSignal })
1026
+ usageTap?.attach(child.stdout as any)
1027
+ const result = await running
1028
+ auditAttachedRun(deps, usageTap, result, provider, nativeThreadId, prompt)
1029
+ return result
1018
1030
  } finally {
1019
1031
  // Every path: success, mismatch, non-zero exit, timeout, throw. A leaked
1020
1032
  // entry lets a recycled pid inherit our self-ownership claim.
@@ -1022,6 +1034,27 @@ async function run(
1022
1034
  }
1023
1035
  }
1024
1036
 
1037
+ /** One row for a run that reached an end: a verified exit, or a result the provider wrote before failing. */
1038
+ function auditAttachedRun(deps: AttachedTurnDeps, tap: SideRunTap | null, result: AttachedTurnResult, provider: AttachedProvider, nativeThreadId: string, prompt: string): void {
1039
+ try {
1040
+ if (!tap || !deps.auditRun) return
1041
+ const realUsage = tap.finish()
1042
+ if (!result.ok && !tap.sawEnd) return
1043
+ deps.auditRun({
1044
+ source: 'g2-attached',
1045
+ caller: result.ok ? 'attached_turn' : `attached_turn:${result.reason}`,
1046
+ model: provider,
1047
+ inputChars: prompt.length,
1048
+ outputChars: tap.outputChars,
1049
+ durationMs: result.durationMs,
1050
+ cliSessionId: result.returnedNativeId ?? nativeThreadId,
1051
+ ...(realUsage ? { realUsage } : {}),
1052
+ })
1053
+ } catch {
1054
+ // The audit never changes a turn's outcome.
1055
+ }
1056
+ }
1057
+
1025
1058
  function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, signal: NodeJS.Signals): void {
1026
1059
  try {
1027
1060
  deps.terminate(child, signal)
@@ -1870,6 +1903,7 @@ export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict):
1870
1903
  return {
1871
1904
  now: () => Date.now(),
1872
1905
  preflight,
1906
+ auditRun: entry => logTokenAudit(entry),
1873
1907
  resolveBinary: provider => resolveProviderBinary(provider),
1874
1908
  spawn: request => {
1875
1909
  // 6.53.3: read BEFORE the spawn call, so every process of this turn starts at or
@@ -13,6 +13,7 @@ import { COS_SCRIPTS_DIR } from './python-bridge.js'
13
13
  import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
14
14
  import { resolveProviderWorkDir } from './launch-dir.js'
15
15
  import { logTokenAudit } from './token-audit.js'
16
+ import { ClaudeRunUsage } from './provider-usage.js'
16
17
  import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
17
18
  import { getHistory, addExchange, setExchangeAttachments, removeExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
18
19
  import { notifySessionStart, notifyExchange } from './telegram-notify.js'
@@ -263,6 +264,7 @@ export async function preWarmCLI(): Promise<void> {
263
264
  })
264
265
 
265
266
  let buffer = ''
267
+ const prewarmUsage = new ClaudeRunUsage(false)
266
268
 
267
269
  proc.stdout.on('data', (chunk: Buffer) => {
268
270
  buffer += chunk.toString()
@@ -274,6 +276,7 @@ export async function preWarmCLI(): Promise<void> {
274
276
  if (!trimmed) continue
275
277
  try {
276
278
  const event = JSON.parse(trimmed)
279
+ prewarmUsage.observe(event)
277
280
  if (event.type === 'result' && event.session_id && !claudeResultErrorMessage(event)) {
278
281
  preWarmedCliSessionId = event.session_id
279
282
  scheduleCliSessionSave()
@@ -287,6 +290,7 @@ export async function preWarmCLI(): Promise<void> {
287
290
  proc.on('close', () => {
288
291
  preWarmInProgress = false
289
292
  const elapsed = Date.now() - start
293
+ const prewarmRealUsage = prewarmUsage.take()
290
294
  logTokenAudit({
291
295
  source: 'g2-prewarm',
292
296
  model: DEFAULT_MODEL,
@@ -294,6 +298,8 @@ export async function preWarmCLI(): Promise<void> {
294
298
  outputChars: 50,
295
299
  durationMs: elapsed,
296
300
  caller: 'prewarm',
301
+ ...(prewarmRealUsage ? { realUsage: prewarmRealUsage } : {}),
302
+ ...(prewarmUsage.cliSessionId ? { cliSessionId: prewarmUsage.cliSessionId } : {}),
297
303
  })
298
304
  if (!preWarmedCliSessionId) {
299
305
  console.warn('[claude-bridge] CLI pre-warm completed but no session ID captured')
@@ -738,6 +744,9 @@ export async function callClaudeStreaming(
738
744
  effortLevel: resolvedEffort ?? getClaudeEffortLevel(),
739
745
  cliModelId: keyProfile ? keyProfile.model : resolveClaudeCliModelId(resolvedModel),
740
746
  })
747
+ // 6.97.0: what this run used, for the audit row only. --resume decides how its totals can be read.
748
+ const runUsage = new ClaudeRunUsage(!!existingCliSession)
749
+ let auditRowWritten = false
741
750
 
742
751
  // Ultracode is a CLI-only orchestration keyword; history/chat keeps the
743
752
  // original user text so the keyword never appears on the lens.
@@ -927,6 +936,7 @@ export async function callClaudeStreaming(
927
936
  const inputEstimate = systemPrompt.length + fullQuery.length + contextPrompt.length
928
937
  // Key lanes (P11): this turn's settled spend on the key. Keyed turns only.
929
938
  const keyUsdFields = keyTurnId ? { usd: Math.round(getSharedKeyMeter().turnSettledUsd(keyTurnId) * 1e6) / 1e6 } : {}
939
+ const realUsage = runUsage.take()
930
940
  logTokenAudit({
931
941
  source: options?.lightweight ? 'g2-voice' : 'g2-query',
932
942
  model: resolvedModel,
@@ -936,7 +946,11 @@ export async function callClaudeStreaming(
936
946
  caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
937
947
  ...keyAuditFields,
938
948
  ...keyUsdFields,
949
+ runId: run.runId,
950
+ ...auditCliSession(),
951
+ ...(realUsage ? { realUsage } : {}),
939
952
  })
953
+ auditRowWritten = true
940
954
 
941
955
  finishClaudeRun(run.runId, {
942
956
  status: 'completed',
@@ -963,6 +977,33 @@ export async function callClaudeStreaming(
963
977
  notifyExchange(sid, query, text)
964
978
  }
965
979
 
980
+ function auditCliSession(): { cliSessionId?: string } {
981
+ const cliSessionId = runUsage.cliSessionId ?? existingCliSession
982
+ return cliSessionId ? { cliSessionId } : {}
983
+ }
984
+
985
+ /**
986
+ * A run can finish on its first result while background subagents are still going; their usage is
987
+ * stated on a later result, after the row above was written. It gets one row of its own under the
988
+ * same run id, so each result counts once and none is lost. Zero chars: it carries only real usage.
989
+ */
990
+ function auditLateUsage() {
991
+ if (!auditRowWritten) return
992
+ const realUsage = runUsage.take()
993
+ if (!realUsage) return
994
+ logTokenAudit({
995
+ source: options?.lightweight ? 'g2-voice' : 'g2-query',
996
+ model: resolvedModel,
997
+ inputChars: 0,
998
+ outputChars: 0,
999
+ durationMs: Date.now() - startTime,
1000
+ caller: `${options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query')}:late_result`,
1001
+ ...keyAuditFields, runId: run.runId,
1002
+ ...auditCliSession(),
1003
+ realUsage,
1004
+ })
1005
+ }
1006
+
966
1007
  async function finalizeError(
967
1008
  msg: string,
968
1009
  exitCode?: number | null,
@@ -1120,6 +1161,7 @@ export async function callClaudeStreaming(
1120
1161
 
1121
1162
  try {
1122
1163
  const event = JSON.parse(trimmed)
1164
+ runUsage.observe(event)
1123
1165
 
1124
1166
  // 6.58.2: the first event that carries the session id (a hook or init line, before any
1125
1167
  // answer text) links the run and the job to it. At completion the result's id is
@@ -1274,6 +1316,11 @@ export async function callClaudeStreaming(
1274
1316
  keyTurn?.childClosed()
1275
1317
  if (steerReaper) clearTimeout(steerReaper)
1276
1318
  steerPort?.close()
1319
+ // 6.97.0: nothing more can arrive. A last line with no newline is read here, once, for the audit.
1320
+ if (buffer.trim()) {
1321
+ try { runUsage.observe(JSON.parse(buffer.trim())) } catch { /* not JSON: handled below */ }
1322
+ }
1323
+ auditLateUsage()
1277
1324
  if (terminationRequested) return
1278
1325
  // Process any remaining buffer
1279
1326
  if (buffer.trim()) {
@@ -17,6 +17,7 @@ import {
17
17
  skipsCosServiceTier,
18
18
  } from './codex-extra-args.js'
19
19
  import { logTokenAudit } from './token-audit.js'
20
+ import { CodexRunUsage } from './provider-usage.js'
20
21
  import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
21
22
  import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
22
23
  import {
@@ -644,6 +645,9 @@ export async function callCodexStreaming(
644
645
  // the user's own words, bound by the hash of exactly what goes on stdin; the file dies
645
646
  // with the child. See lib/turn-request.ts.
646
647
  const turnRequest = createTurnRequestBinding({ userText: query, prompt, turnId: options?.turnId })
648
+ // 6.97.0: what this run used, for the audit row only. Begins before the spawn, which writes to the rollout.
649
+ const runUsage = new CodexRunUsage(keyProfile ? keyProfile.model : !skipsCosModel(extra) ? resolvedCodexModel?.id : undefined)
650
+ runUsage.begin(engineSession?.codexThreadId)
647
651
  applyTurnRequestEnv(env, turnRequest)
648
652
 
649
653
  const proc = withKeyTurnRevokedOnThrow(keyTurn, () => spawnWithTurnRequest(turnRequest, () => spawn(resolvedCodex.path, args, {
@@ -811,6 +815,7 @@ export async function callCodexStreaming(
811
815
  const totalMs = Date.now() - startTime
812
816
  // Key lanes (P11): this turn's settled spend on the key. Keyed turns only.
813
817
  const keyUsdFields = keyTurnId ? { usd: Math.round(getSharedKeyMeter().turnSettledUsd(keyTurnId) * 1e6) / 1e6 } : {}
818
+ const realUsage = runUsage.finish(codexThreadId, engineSession?.codexThreadId)
814
819
  logTokenAudit({
815
820
  source: options?.lightweight ? 'g2-voice' : 'g2-query',
816
821
  model,
@@ -820,6 +825,9 @@ export async function callCodexStreaming(
820
825
  caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
821
826
  ...keyAuditFields,
822
827
  ...keyUsdFields,
828
+ runId: run.runId,
829
+ ...(codexThreadId ? { cliSessionId: codexThreadId } : {}),
830
+ ...(realUsage ? { realUsage } : {}),
823
831
  })
824
832
  saveEngineSessionBestEffort()
825
833
 
@@ -948,6 +956,7 @@ export async function callCodexStreaming(
948
956
 
949
957
  let nativeSessionAnnounced = false
950
958
  function handleEvent(event: any) {
959
+ runUsage.observe(event)
951
960
  const nextThreadId = extractCodexThreadId(event)
952
961
  if (nextThreadId && nextThreadId !== codexThreadId) {
953
962
  codexThreadId = nextThreadId
@@ -1032,10 +1041,11 @@ export async function callCodexStreaming(
1032
1041
 
1033
1042
  proc.on('close', (code) => {
1034
1043
  keyTurn?.childClosed()
1035
- if (terminationRequested) return
1044
+ if (terminationRequested) { runUsage.settle(); return }
1036
1045
  if (buffer.trim()) {
1037
1046
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
1038
1047
  }
1048
+ runUsage.settle()
1039
1049
  if (finalized) return
1040
1050
  if (fullText) {
1041
1051
  void finalize(fullText)
@@ -0,0 +1,42 @@
1
+ // 6.97.0: how far a Codex thread's rollout has been written, for the audit only. Codex's usage is a
2
+ // running total for the thread, so charging a resumed turn the difference is only honest if nothing
3
+ // else ran on the thread since. The rollout's size is the one thing that shows a desktop run.
4
+ //
5
+ // Any write moves it, including ones that are not a turn. That direction is safe: the row stays estimated.
6
+ import { statSync } from 'node:fs'
7
+ import { realNativeHeadDeps, transcriptPathFor, type NativeHeadDeps } from './native-head.js'
8
+ import { registerCodexRolloutProbe } from './provider-usage.js'
9
+
10
+ /** A located rollout stays put, so a turn start costs one stat, not a walk of the sessions tree. */
11
+ const rolloutPaths = new Map<string, string>()
12
+ const ROLLOUT_PATHS_MAX = 512
13
+
14
+ /** Bytes written so far, or null for UNKNOWN: no rollout found, or it moved or was compressed. Never throws. */
15
+ export function codexRolloutSize(threadId: string, deps?: NativeHeadDeps): number | null {
16
+ try {
17
+ const known = rolloutPaths.get(threadId)
18
+ if (known) {
19
+ try {
20
+ return statSync(known).size
21
+ } catch {
22
+ rolloutPaths.delete(threadId)
23
+ }
24
+ }
25
+ const path = transcriptPathFor('codex', threadId, deps ?? realNativeHeadDeps())
26
+ if (!path) return null
27
+ rolloutPaths.set(threadId, path)
28
+ if (rolloutPaths.size > ROLLOUT_PATHS_MAX) rolloutPaths.delete(rolloutPaths.keys().next().value!)
29
+ return statSync(path).size
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** Called once at boot, in production only. */
36
+ export function installCodexRolloutProbe(): void {
37
+ registerCodexRolloutProbe(threadId => codexRolloutSize(threadId))
38
+ }
39
+
40
+ export function _resetCodexRolloutPathsForTest(): void {
41
+ rolloutPaths.clear()
42
+ }
@@ -533,7 +533,8 @@ export async function callCursorStreaming(
533
533
  runId: run.runId,
534
534
  sessionId: sid,
535
535
  clientJobId: options?.clientJobId,
536
- usageKind: 'estimated',
536
+ // 6.97.0: the chat id only. Cursor's usage stays estimated: its cache parser has no real rows.
537
+ ...(cursorChatId ? { cliSessionId: cursorChatId } : {}),
537
538
  })
538
539
  saveEngineSessionBestEffort()
539
540
 
@@ -154,11 +154,13 @@ export function resetSubqDecisionClients(): void { configuredSubqClients.forEach
154
154
  /** A SubQ client built outside createDecisionClient (live cues, 6.76.2) still starts clean when a new key is saved. */
155
155
  export function trackSubqDecisionClient(client: SubqDecisionClient): SubqDecisionClient { configuredSubqClients.add(client); return client }
156
156
  /** Snapshot selection per workflow request. Separate clients and cache identities prevent stale cross-provider answers. */
157
- export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env): DecisionClient {
157
+ export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env,
158
+ /** Optional: a SubQ ledger file and daily cap of this feature's own. Without it SubQ uses `subq-<useCase>-usage.json` and COS_SUBQ_DAILY_TOKENS. */
159
+ subq: { usageFile?: string; cap?: () => number } = {}): DecisionClient {
158
160
  const clients: Partial<Record<DecisionProvider, DecisionClient>> = {}
159
161
  const snapshot = (): DecisionClient => {
160
162
  const selected = selectedDecisionProvider(useCase, env)
161
- if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), dataPath(`subq-${useCase}-usage.json`)) : {
163
+ if (!clients[selected]) clients[selected] = selected === 'subq' ? new SubqDecisionClient(fetch, () => new Date(), subq.usageFile ?? dataPath(`subq-${useCase}-usage.json`), undefined, subq.cap) : {
162
164
  provider: 'jev', async ask(state, questions, estimate, options) {
163
165
  if (options?.images?.length) throw new DecisionProviderError('decision_images_unsupported')
164
166
  return { ...await jev.ask(state, questions, estimate, options), provider: 'jev' }