@gotcos/glasses-server 6.64.0 → 6.66.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.
package/.env.example CHANGED
@@ -340,3 +340,8 @@ BIND_HOST=0.0.0.0
340
340
  # additionally requires a client that acknowledges displaying continue_note.
341
341
  # Keep this in ~/.cos-glasses/.env; Control does not yet persist it on update.
342
342
  COS_CONTINUE_FULL_PERMISSIONS=1
343
+
344
+ # Optional absolute executable paths for COS-managed whisper.cpp.
345
+ # Unset uses Homebrew then PATH. An invalid explicit path fails closed.
346
+ # COS_WHISPER_CLI_BIN=/absolute/path/to/whisper-cli
347
+ # COS_WHISPER_SERVER_BIN=/absolute/path/to/whisper-server
package/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 6.66.0 — 2026-10-08 — Managed voice setup and Haiku polish
2
+
3
+ - Advertise Haiku, Sonnet and GPT-5.6 Luna Fast. Default omitted-model polish to Haiku for lower cost, while honoring explicit model choices and retaining the transcript on failure.
4
+ - Glasses app 6.10.616 explicitly sends the chosen model and already works with server 6.65.0.
5
+ - Setup, live preview, committed transcription and HQ share explicit `COS_WHISPER_CLI_BIN` / `COS_WHISPER_SERVER_BIN` resolution. A broken explicit path fails closed; existing Homebrew/PATH detection remains the fallback.
6
+ - COS Control can prepare Turbo first for a local benchmark, then the chosen tier, without overwriting saved voice settings. Both preparation flags require `--setup-transcription --prepare-only`.
7
+ - Pairs with COS Control 0.5.273 and the signed/notarized whisper.cpp 1.9.1 runtime.
8
+ - Claude health probes resolve the installed absolute binary path, including managed-service environments without the interactive shell PATH.
9
+
10
+ ## 6.65.0 — 2026-10-07 — Work search by meaning, and task dates
11
+
12
+ - `POST /api/work/search` `{ query, domain?, scope?, ids? }` finds the Work card the user means, not only the words they typed. Pairs with COS Control 0.5.259, which shows its own word matches at once and asks this after a pause.
13
+ - One Jev Choice per search over the board's own cards, plus `none`. The client names a view or exact card ids, never task text. A domain board searches that domain's cards, open and completed. All work searches open cards only, although Control's All work view also shows completed ones. Completed searches completed cards. Needs attention and In progress send their card ids.
14
+ - What goes to TypeSafe: each card's task text, on one line, at most 300 characters, without `[Work details](…)` links. URLs, email addresses and opaque ids (the shape of a key or a document id) are removed, credentials are masked by the server's secret redaction, and a markdown link keeps only its label. A bare domain such as bottlepos.com stays.
15
+ - The query is 2 to 200 characters, counted in code points, so 150 emoji are 150.
16
+ - The answer is `{ available: true, results: [{ id, p }], none, cached, tokens }`: cards at p 0.02 or more, most likely first, at most 20.
17
+ - When it cannot search it answers HTTP 200 with `{ available: false, reason, message }`. The reasons are `jev_not_configured`, `jev_key_rejected`, `jev_cap_reached`, `jev_breaker_open`, `jev_request_rejected` (Jev refused the request: a 400, 413 or 422), `jev_unavailable` (no answer, a 5xx, or a malformed answer), `search_off` and `too_many_candidates`. A Choice holds 254 cards, so a bigger set is refused, never cut; Completed across all domains is 198 cards today. Control keeps its word search in every case. The server logs each reason at most once per 10 minutes.
18
+ - Its own cost controls, apart from Work intake and session advice. A daily cap of 1,000,000 input tokens (`COS_JEV_SEARCH_DAILY_TOKENS`), about 110 to 200 searches by board size, with its own ledger (`<data>/jev-search-usage.json`); it resets at midnight UTC. Its own breaker: 3 failures, then an hour; a refused request does not count, because the same cards would be refused again. A switch, `COS_JEV_SEARCH=0`. Both come from the server's environment, so changing them takes a restart. Saving a new TypeSafe key clears this breaker too. `COS_JEV_DAILY_TOKENS` is unchanged.
19
+ - The same query over the same cards is answered from memory for 10 minutes (up to 200 searches).
20
+ - Search is read-only, so it does not take the server's request lease: an update or restart never waits on a search, and a search during one is answered rather than refused.
21
+ - Measured on 2026-10-06 on the live board (293 cards, 95 open): 4 searches, the right card first at p 0.99 for each of 3 real queries, `none` at 1 for an unrelated one, 4,815 to 4,963 input tokens and 268 to 431 ms each including the board read. The repeat came from the cache with no call.
22
+ - `GET /api/tasks` and `GET /api/work-board` rows carry `createdOn` (`YYYY-MM-DD` or null), `createdFrom` (`source`, `git` or null) and `lineChangedAt` (ISO time or null), for Control's date orders. Control reads `/api/work-board` for its Work board, so both routes carry them. Additive: every existing field and the task revision are unchanged, and the fields are present as null when unknown, so a client can tell this server from an older one.
23
+ - `createdOn` is the first date in the task's source label, usually the meeting date. Otherwise it is the day the task's words first appeared in the git history of its `tasks.md`, cached on disk (`<data>/task-created-dates.json`, at most 5,000) by file and a hash of the words. A lookup that times out or fails is not cached as "not in history"; it is tried again after 10 minutes.
24
+ - `lineChangedAt` is when git last changed the task's line (blame, once per HEAD and file change). A line that is not committed yet is null, never "now". tasks.md is committed by the COS sync every one to three days, so this is later than the real change.
25
+ - Every read of either route, the phone's and the glasses' included, keeps all of its git work inside 1.5 s, half the phone's 4 s timeout less an allowance for the board read. Past that the dates still unknown come back null and the git work finishes in the background for a later read.
26
+ - Read-only: nothing is written into tasks.md. Git runs only when a `.git` exists at or above the `tasks.md` folder. A public install keeps its tasks outside any repository, so no git process starts. If the home folder is itself a git repository, git does run, finds `tasks.md` untracked, and the git fields stay null. Both the COS bridge and the packaged task runtime get the fields.
27
+ - Measured on 2026-10-06 on the live board (293 cards, a read-only scratch run): 241 dated from the source label, 52 from git, none without a date; 278 with `lineChangedAt` (the other 15 are not committed yet). The first call took 1.2 s (68 git lookups), later calls about 20 ms, and a restart 74 ms with no git history lookup.
28
+
1
29
  ## 6.64.0 — 2026-10-06 — Meeting context keys
2
30
 
3
31
  - Meeting rows and details carry `contextKeys`, `contextSupported`, and, when files can't be added, `contextReason` (plus `contextAmbiguous`). The fields are additive and read-only. They are the keys COS Control 0.5.258 files a meeting's dropped screenshots, slides and documents under, so the files go to every Work card linked to the meeting.
package/README.md CHANGED
@@ -646,18 +646,24 @@ and counts only, never a speaker name, path or error text; those stay in
646
646
 
647
647
  ## Final dictation polish (6.61.5)
648
648
 
649
- With glasses app 6.10.573, **Settings → Voice → Polish model** offers **Sonnet**
650
- (the default, using your Claude CLI sign-in) and **GPT-5.6 Luna Fast** (using your
651
- Cursor CLI sign-in). Luna uses `gpt-5.6-luna-none-fast`; it needs server 6.61.5.
652
- Both phone text and recovered audio drafts honor the choice. The server advertises
653
- these options in `capabilities.dictationCleanup.models` on health and models.
649
+ With glasses app 6.10.616, **Settings → Voice → Polish model** offers **Haiku 5.5**
650
+ (the lower-cost default), **Sonnet 5.5** (higher-quality polish), and **GPT-5.6 Luna Fast**.
651
+ Haiku and Sonnet use your Claude CLI sign-in; Luna uses your Cursor CLI sign-in.
652
+ Luna uses `gpt-5.6-luna-none-fast` and needs server 6.61.5. Existing explicit choices
653
+ are preserved. Unset or unknown app preferences use Haiku. Both phone text and
654
+ recovered audio drafts honor the choice. Existing server 6.65.0 already accepts
655
+ explicit Haiku requests, so the new app does not require a server update.
656
+
657
+ The next server release also defaults omitted-model requests to Haiku and advertises
658
+ all three options in `capabilities.dictationCleanup.models`. Until that release,
659
+ 6.65.0 still defaults requests without a model to Sonnet; 6.10.616 sends its choice
660
+ explicitly. This is a polish-model change, not a change to the speech recognition engine.
654
661
 
655
662
  Cleanup uses subscription CLIs only, with no API-key fallback or automatic model
656
663
  substitution. A failed or unavailable cleanup keeps the glossary-corrected
657
664
  transcript. Turn **Final polish (AI)** off to skip the model call. Claude tools
658
665
  and hooks are disabled; Cursor runs in read-only ask mode with a temporary profile
659
666
  and empty workspace. Live preview and canonical meeting transcription are unchanged.
660
- Older clients may still explicitly request Haiku.
661
667
 
662
668
  ## HQ dictation
663
669
 
@@ -917,3 +923,23 @@ This is a local owner instance, separate from app.gotcos.com tenants and hosted
917
923
  ## Provider parity in 6.62.0
918
924
 
919
925
  See [provider capabilities and limits](docs/provider-parity.md) for Continue permissions, client compatibility, hook trust, observation limits and rollback. Glasses 6.10.600 shows the server's Continue note. Older clients retain Cursor Ask behavior; forks remain read-only.
926
+
927
+ ### Managed Whisper runtime (6.66.0 candidate)
928
+
929
+ COS Control 0.5.272 prepares a signed, notarized whisper.cpp 1.9.1 runtime during
930
+ voice setup on Apple silicon. It supplies absolute `COS_WHISPER_CLI_BIN` and
931
+ `COS_WHISPER_SERVER_BIN` paths to the server; Homebrew is not needed for that flow.
932
+ Direct server installs still fall back to Homebrew and then PATH when overrides
933
+ are unset. A configured but missing/non-executable override is an error, never a
934
+ silent change of runtime.
935
+
936
+ `--setup-transcription --prepare-only --voice-benchmark-prepare` prepares Turbo
937
+ without changing saved tier keys. Control measures the device and follows with
938
+ `--setup-transcription --prepare-only --transcription-tier balanced|max
939
+ --preserve-voice-settings`. The latter prepares all required models, including
940
+ Large-v3 for saved-meeting HQ in both tiers. Applying a tier remains Control's
941
+ transactional action. Downloading or cancelling preparation does not change it.
942
+ Runtime/model setup is local; the existing provider sign-in requirements remain.
943
+
944
+ Candidate only: publish the versioned runtime asset and server package before
945
+ promoting the matching Control appcast entry.
package/bin/cli.cjs CHANGED
@@ -25,7 +25,12 @@ const { homedir } = require('os')
25
25
  const PKG_ROOT = resolve(__dirname, '..')
26
26
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
27
27
  const PREPARE_ONLY = process.argv.includes('--prepare-only')
28
+ const VOICE_BENCHMARK_PREPARE = process.argv.includes('--voice-benchmark-prepare')
28
29
  const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
30
+ if ((VOICE_BENCHMARK_PREPARE || process.argv.includes('--preserve-voice-settings')) && !(SETUP_TRANSCRIPTION && PREPARE_ONLY)) {
31
+ console.error('Voice preparation flags require --setup-transcription --prepare-only.');
32
+ process.exit(64)
33
+ }
29
34
  const SETUP_SPEAKER_MODEL = process.argv.includes('--setup-speaker-model')
30
35
  const HOOKS_ACTION = process.argv.includes('--hooks') ? process.argv[process.argv.indexOf('--hooks') + 1] : undefined
31
36
  function optionValue(name) {
@@ -540,12 +545,15 @@ function upsertEnvValue(file, key, value) {
540
545
  chmodSync(file, 0o600)
541
546
  }
542
547
 
543
- if (SETUP_TRANSCRIPTION) {
548
+ if (SETUP_TRANSCRIPTION && !VOICE_BENCHMARK_PREPARE) {
549
+ const preserveVoiceSettings = process.argv.includes('--preserve-voice-settings')
544
550
  const previewModel = TRANSCRIPTION_TIER === 'max' ? 'turbo' : 'small.en'
545
551
  const commitModel = TRANSCRIPTION_TIER === 'max' ? 'large-v3' : 'turbo'
546
- upsertEnvValue(ENV_FILE, 'COS_WHISPER_TRANSCRIPTION_TIER', TRANSCRIPTION_TIER)
547
- upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', previewModel)
548
- upsertEnvValue(ENV_FILE, 'COS_WHISPER_COMMIT_MODEL', commitModel)
552
+ if (!preserveVoiceSettings) {
553
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_TRANSCRIPTION_TIER', TRANSCRIPTION_TIER)
554
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', previewModel)
555
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_COMMIT_MODEL', commitModel)
556
+ }
549
557
  process.env.COS_WHISPER_TRANSCRIPTION_TIER = TRANSCRIPTION_TIER
550
558
  process.env.COS_WHISPER_PREVIEW_MODEL = previewModel
551
559
  process.env.COS_WHISPER_COMMIT_MODEL = commitModel
@@ -573,8 +581,7 @@ if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
573
581
 
574
582
  // Step 5: local Whisper detection + model download. Voice stays local-only by
575
583
  // default; cloud fallback requires an explicit flag plus a configured key.
576
- const WHISPER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli']
577
- const WHISPER_SERVER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whisper-server']
584
+ const { resolveWhisperBinary } = require('./whisper-runtime.cjs')
578
585
  const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
579
586
  const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
580
587
  const WHISPER_MODEL_PARTIAL = WHISPER_MODEL_PATH + '.partial'
@@ -591,24 +598,8 @@ const WHISPER_LARGE_MODEL_MIN_BYTES = 2_800_000_000
591
598
  const WHISPER_MODEL_EXPECTED_BYTES = 1_624_555_275
592
599
  const WHISPER_SMALL_MODEL_EXPECTED_BYTES = 487_614_201
593
600
  const WHISPER_LARGE_MODEL_EXPECTED_BYTES = 3_095_033_483
594
- function findWhisperCli() {
595
- for (const p of WHISPER_KNOWN_PATHS) { if (existsSync(p)) return p }
596
- try {
597
- const found = execSync('command -v whisper-cli 2>/dev/null', { shell: '/bin/sh', stdio: 'pipe', timeout: 2000 }).toString().trim()
598
- return found || null
599
- } catch {
600
- return null
601
- }
602
- }
603
- function findWhisperServer() {
604
- for (const p of WHISPER_SERVER_KNOWN_PATHS) { if (existsSync(p)) return p }
605
- try {
606
- const found = execSync('command -v whisper-server 2>/dev/null', { shell: '/bin/sh', stdio: 'pipe', timeout: 2000 }).toString().trim()
607
- return found || null
608
- } catch {
609
- return null
610
- }
611
- }
601
+ function findWhisperCli() { return resolveWhisperBinary('whisper-cli') }
602
+ function findWhisperServer() { return resolveWhisperBinary('whisper-server') }
612
603
  function isValidWhisperModel(p, minBytes = WHISPER_MODEL_MIN_BYTES) {
613
604
  if (!existsSync(p)) return false
614
605
  try { return statSync(p).size >= minBytes } catch { return false }
@@ -652,10 +643,10 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
652
643
  mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
653
644
  const targets = [
654
645
  [WHISPER_MODEL_PATH, WHISPER_MODEL_PARTIAL, WHISPER_MODEL_MIN_BYTES, WHISPER_MODEL_EXPECTED_BYTES],
655
- ...(TRANSCRIPTION_TIER === 'balanced'
646
+ ...(!VOICE_BENCHMARK_PREPARE && TRANSCRIPTION_TIER === 'balanced'
656
647
  ? [[WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES]]
657
648
  : []),
658
- [WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES],
649
+ ...(!VOICE_BENCHMARK_PREPARE ? [[WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES]] : []),
659
650
  ]
660
651
  const missingBytes = targets.reduce((sum, [path, , minimum, expected]) =>
661
652
  sum + (isValidWhisperModel(path, minimum) ? 0 : expected), 0)
@@ -716,7 +707,7 @@ if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
716
707
 
717
708
  const previewChoice = (process.env.COS_WHISPER_PREVIEW_MODEL || process.env.COS_WHISPER_REALTIME_MODEL || 'auto').trim().toLowerCase()
718
709
  const wantsSmallPreview = ['small', 'small.en', 'ggml-small.en.bin'].includes(previewChoice)
719
- if (whisperCliPath && wantsSmallPreview) {
710
+ if (whisperCliPath && wantsSmallPreview && !VOICE_BENCHMARK_PREPARE) {
720
711
  if (isValidWhisperModel(WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_MIN_BYTES)) {
721
712
  console.log(green(' ✓') + ' Small.en preview model ready ' + dim('— Turbo remains authoritative'))
722
713
  } else {
@@ -745,7 +736,7 @@ if (whisperCliPath && wantsSmallPreview) {
745
736
  }
746
737
  }
747
738
 
748
- if (whisperCliPath && SETUP_TRANSCRIPTION) {
739
+ if (whisperCliPath && SETUP_TRANSCRIPTION && !VOICE_BENCHMARK_PREPARE) {
749
740
  if (isValidWhisperModel(WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_MIN_BYTES)) {
750
741
  const largeRole = TRANSCRIPTION_TIER === 'max'
751
742
  ? 'live preview + commit and saved-meeting polish'
@@ -789,7 +780,7 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
789
780
  // -- sixty times larger -- downloaded here with SKIP_WHISPER_DOWNLOAD as the
790
781
  // escape hatch. Same shape, same opt-out, so nobody on a metered or restricted
791
782
  // network is forced into it.
792
- if (!SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
783
+ if (!VOICE_BENCHMARK_PREPARE && !SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
793
784
  const spkDest = join(CONFIG_DIR, 'models', SPEAKER_MODEL.filename)
794
785
  if (existsSync(spkDest) && sha256File(spkDest) === SPEAKER_MODEL.sha256) {
795
786
  console.log(green(' ✓') + ' Voiceprint model ready ' + dim('— named speakers available'))
@@ -0,0 +1,17 @@
1
+ // Shared by setup and both live Whisper workers. Explicit overrides fail closed.
2
+ const { accessSync, constants, statSync } = require('node:fs')
3
+ const { isAbsolute, join } = require('node:path')
4
+ function executable(path) {
5
+ try { accessSync(path, constants.X_OK); return statSync(path).isFile() } catch { return false }
6
+ }
7
+ function resolveWhisperBinary(name, env = process.env, available = executable) {
8
+ if (!['whisper-cli', 'whisper-server'].includes(name)) throw new Error('Unknown Whisper binary')
9
+ const key = name === 'whisper-cli' ? 'COS_WHISPER_CLI_BIN' : 'COS_WHISPER_SERVER_BIN'
10
+ if (env[key] !== undefined && env[key] !== '') {
11
+ const path = env[key].trim()
12
+ return isAbsolute(path) && available(path) ? path : null
13
+ }
14
+ const candidates = ['/opt/homebrew/bin', '/usr/local/bin', ...(env.PATH || '').split(':')]
15
+ return candidates.filter(isAbsolute).map(p => join(p, name)).find(available) || null
16
+ }
17
+ module.exports = { resolveWhisperBinary }
@@ -0,0 +1 @@
1
+ export function resolveWhisperBinary(name: 'whisper-cli' | 'whisper-server', env?: NodeJS.ProcessEnv, available?: (path: string) => boolean): string | null
@@ -35,6 +35,8 @@ Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{
35
35
 
36
36
  Since 6.58.0 `POST /api/work-board/completion-check` takes exactly `{ domain, id, provider, sessionId }` plus an optional `after` (ISO time). It asks Jev whether the session's replies since `after` (or its newest reply) show the task finished, judged against its Done when or, without one, its own text. The server reads the task from the board and the replies from its own transcripts; the client sends names and a time only. It answers `{ provider: "jev", verdict: "done" | "not_done" | "unclear", confidence, basis: "done_when" | "task", model, cached }` or `{ provider: "none", reason }` (`no_reply`, `no_task_text`, a Jev condition, `completion_unavailable`). Errors: 400 `invalid_completion_request`, 404 `task_not_found`, 404 `session_not_found`. COS Control 0.5.247 asks only while the session is idle and its newest reply since the handoff has no `COS-WORK` status line, at most twice per handoff, and moves a card to QA only on done at 0.8 (0.85 without a Done when).
37
37
 
38
+ Since 6.65.0 `POST /api/work/search` takes `{ query, domain?, scope?, ids? }`: a query of 2 to 200 characters (code points), an optional domain, an optional scope (`all`, `attention`, `progress` or `completed`) and optional card ids (12 hex). It asks Jev one Choice over the board's own cards, plus `none`, and answers `{ available: true, results: [{ id, p }], none, cached, tokens }` with cards at p 0.02 or more, most likely first, at most 20. Exact ids win (Control sends them for Needs attention and In progress). Otherwise a domain board covers that domain's cards, All work its open cards, and Completed its completed cards. Each card goes to TypeSafe as its task text without `[Work details](…)` links, URLs, email addresses or opaque ids, with credentials masked by the server's secret redaction, at most 300 characters. A search that cannot run answers 200 with `{ available: false, reason, message }`: `jev_not_configured`, `jev_key_rejected`, `jev_cap_reached`, `jev_breaker_open`, `jev_request_rejected` (a 400, 413 or 422 from Jev; not counted toward the breaker), `jev_unavailable`, `search_off` (`COS_JEV_SEARCH=0`) or `too_many_candidates` (more than 254 cards). Errors: 400 `invalid_search_request`, 503 `work_board_unavailable`. Search has its own daily cap (`COS_JEV_SEARCH_DAILY_TOKENS`, default 1,000,000, reset at midnight UTC), ledger (`data/jev-search-usage.json`) and breaker, so it never spends the cap Work intake and session advice share; the switch and the cap are read from the environment set at start, so a change takes a restart. The same query over the same cards is answered from memory for 10 minutes. The route is read-only and does not take the server's request lease.
39
+
38
40
  Since 6.58.2 `POST /api/query-jobs` also takes an optional `sessionName` (a string; the server cleans it as the 6.58.2 changelog says and keeps at most 100 characters; anything else is no name, never an error). It names only a Claude session the job starts itself (`claude --name=<name>`, when the installed CLI lists `--name`); a Continue, a dispatch, a Codex, Cursor or Ollama job, and an older server ignore it, and it is not part of the request fingerprint. A Claude job's snapshot carries `cliSessionId` from Claude's first stream event while the job is still `running`, not only at completion. COS Control 0.5.250 sends the Work item's title for New session, Start work and Fork to Claude, and links its receipt as soon as the running job names the session.
39
41
 
40
42
  Since 6.59.0 `GET /api/work-board/activity` also projects each handoff's progress (the report, its evidence of at most 280 characters, the newest move), the task's own revision and a saved destination, `GET /api/work-board/activity/open` lists every open handoff across the board, and `POST /api/work-board/handoff-requests` lets the glasses ask COS Control to start Work, reply, or say it is not done yet, which Control claims on this Mac and sends with its own guards. The server runs no provider and no Jev for any of it. See `docs/work-handoff-requests.md`.
@@ -30,6 +30,17 @@ single-command child pins its domain as cwd instead of relying on unsupported
30
30
  atomic replacement writes use exclusive no-follow temporary files. Same-user
31
31
  modification of the installed package/manifest is outside this integrity boundary.
32
32
 
33
+ Since 6.65.0 `GET /api/tasks` and `GET /api/work-board` rows also carry `createdOn`,
34
+ `createdFrom` and `lineChangedAt`, for both the COS bridge and this runtime. The server reads them; the
35
+ runtime does not. `createdOn` is the first `YYYY-MM-DD` in the source label, else the
36
+ day the task's words first appeared in the git history of its `tasks.md`.
37
+ `lineChangedAt` is the git blame time of a committed line. Each is null when unknown,
38
+ including every git-derived value when no git repository tracks the `tasks.md`.
39
+ All git work for one read (rev-parse, blame, first-seen lookups) shares a 1.5 s
40
+ deadline; what is still unknown then comes back null, and the git work finishes in the
41
+ background for a later read. Git runs only when a `.git` exists at or above the
42
+ `tasks.md` folder. Nothing is written into `tasks.md`.
43
+
33
44
  The default lock namespace remains `~/Library/Application Support/COS/.task_locks.json`
34
45
  and canonical `file:<domain>` entries. `COS_TASK_LOCK_STORE` is an explicit override
35
46
  for isolated fixtures. Existing COS bridge calls keep their original environment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.64.0",
3
+ "version": "6.66.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": {
@@ -46,6 +46,7 @@
46
46
  "!server/scripts/work-review-mutation-gate.mjs",
47
47
  "!server/scripts/work-intake-mutation-gate.mjs",
48
48
  "!server/scripts/work-progress-mutation-gate.mjs",
49
+ "!server/scripts/work-search-mutation-gate.mjs",
49
50
  "!server/scripts/away-hold-mutation-gate.mjs",
50
51
  "!server/scripts/nemotron-mutation-gate.mjs",
51
52
  "!server/scripts/merged-scribe-mutation-gate.mjs",
@@ -76,7 +77,9 @@
76
77
  "docs/work-task-runtime.md",
77
78
  "!server/scripts/build-work-task-runtime.py",
78
79
  "bin/hooks/cos-cursor-observer.cjs",
79
- "docs/provider-parity.md"
80
+ "docs/provider-parity.md",
81
+ "bin/whisper-runtime.cjs",
82
+ "bin/whisper-runtime.d.cts"
80
83
  ],
81
84
  "author": "COS Contributors",
82
85
  "license": "MIT",
package/server/index.ts CHANGED
@@ -142,6 +142,8 @@ import { createWorkBoardRouter } from './routes/work-board.js'
142
142
  import { createWorkIntakeRouter } from './routes/work-intake.js'
143
143
  import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
144
144
  import { createJevRouter } from './routes/jev.js'
145
+ import { createWorkSearchRouter } from './routes/work-search.js'
146
+ import { isReadOnlyApiPost } from './lib/work-search.js'
145
147
  import { dataPath } from './lib/data-dir.js'
146
148
  import { loadJsonOrQuarantine, durableAtomicWriteFileSync } from './lib/atomic-fs.js'
147
149
  import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
@@ -324,6 +326,9 @@ app.use('/api', requireApiToken(API_TOKEN))
324
326
  // through their true terminal boundary.
325
327
  app.use('/api', (req, res, next) => {
326
328
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
329
+ // 6.65.0: a POST that only reads (Work search: a board read and one Jev question) is not a mutation. See
330
+ // lib/work-search.ts isReadOnlyApiPost for why holding the lease would hurt and why skipping it is safe.
331
+ if (isReadOnlyApiPost(req.method, req.path)) return next()
327
332
  // V2 video original chunks are bounded to the advertised session size (1 MiB
328
333
  // on new sessions, 256 KiB on leftover drafts) and commit through the upload
329
334
  // registry's own generation lock. Holding the global mutation lease while
@@ -756,6 +761,8 @@ app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
756
761
  // Jev key (Control Settings), Continue/Fork/New session recommendations for Work tasks (6.57.0) and meeting reviews
757
762
  // (6.57.1), and the Work completion check (6.58.0).
758
763
  app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workReviewRuntime.peek(id) } : {}))
764
+ // Work search by meaning (6.65.0, Control 0.5.259): one Jev Choice over the board's cards, with its own cap, switch and breaker.
765
+ app.use('/api', createWorkSearchRouter())
759
766
  app.use('/api', queryRouter)
760
767
  // The scheduled start-of-day brief: settings, status, run-now. Same auth as
761
768
  // every other settings route; the brief itself is an ordinary durable job.
@@ -1,6 +1,6 @@
1
- /** Public choices. Haiku remains accepted only for older clients. */
2
- export const DICTATION_CLEAN_MODELS = ['sonnet', 'luna-5.6-fast'] as const
3
- export type DictationCleanModel = typeof DICTATION_CLEAN_MODELS[number] | 'haiku'
1
+ /** Public polish choices. Haiku is the cost-first default. */
2
+ export const DICTATION_CLEAN_MODELS = ['haiku', 'sonnet', 'luna-5.6-fast'] as const
3
+ export type DictationCleanModel = typeof DICTATION_CLEAN_MODELS[number]
4
4
  export const LUNA_DICTATION_CLI_MODEL = 'gpt-5.6-luna-none-fast'
5
5
  export function isDictationCleanModel(value: unknown): value is DictationCleanModel {
6
6
  return value === 'sonnet' || value === 'luna-5.6-fast' || value === 'haiku'
@@ -32,7 +32,7 @@ export async function autoCleanDictation(
32
32
  opts: { model?: string; signal?: AbortSignal } = {},
33
33
  ): Promise<string> {
34
34
  if (opts.signal?.aborted) throw new Error('Auto-clean aborted')
35
- const model = (opts.model || process.env.COS_DICTATION_AUTOCLEAN_MODEL || 'sonnet').toLowerCase()
35
+ const model = (opts.model || process.env.COS_DICTATION_AUTOCLEAN_MODEL || 'haiku').toLowerCase()
36
36
  if (!isDictationCleanModel(model)) throw new Error('Unsupported dictation cleanup model')
37
37
  const cursor = model === 'luna-5.6-fast'
38
38
  const binary = resolveProviderBinary(cursor ? 'cursor' : 'claude')
@@ -123,9 +123,11 @@ async function probePython(): Promise<string> {
123
123
  }
124
124
  }
125
125
 
126
- async function probeClaude(): Promise<{ value: string; available: boolean }> {
126
+ export async function probeClaude(): Promise<{ value: string; available: boolean }> {
127
127
  try {
128
- const result = await execute('claude', ['--version'])
128
+ const resolved = resolveProviderBinary('claude')
129
+ if (!resolved.ok) return { value: `unresolved (${resolved.detail})`, available: false }
130
+ const result = await execute(resolved.path, ['--version'])
129
131
  return { value: firstNonemptyLine(result.stdout) ?? 'available', available: true }
130
132
  } catch {
131
133
  return { value: 'error', available: false }
package/server/lib/jev.ts CHANGED
@@ -88,8 +88,10 @@ export class JevClient {
88
88
  private failures = 0
89
89
  private breakerUntil = 0
90
90
  private lastError: string | null = null
91
+ /** `cap` is the daily input-token cap for THIS client's ledger. Work search (6.65.0) passes its own; the default is
92
+ * COS_JEV_DAILY_TOKENS, shared by Work intake advice, session recommendations and completion checks. */
91
93
  constructor(private readonly fetchImpl: Fetch = fetch, private readonly now: () => Date = () => new Date(),
92
- private readonly usageFile = JEV_USAGE_FILE) {}
94
+ private readonly usageFile = JEV_USAGE_FILE, private readonly cap: () => number = dailyCap) {}
93
95
 
94
96
  usedToday(): number {
95
97
  try {
@@ -121,7 +123,7 @@ export class JevClient {
121
123
  status(): { configured: boolean; source: JevKeySource; savedAt?: string; validatedAt?: string; usedToday: number; dailyCap: number; breakerOpenUntil: string | null; lastError: string | null } {
122
124
  const key = resolveJevKey()
123
125
  return { configured: !!key, source: key?.source ?? 'none', savedAt: key?.savedAt, validatedAt: key?.validatedAt,
124
- usedToday: this.usedToday(), dailyCap: dailyCap(),
126
+ usedToday: this.usedToday(), dailyCap: this.cap(),
125
127
  breakerOpenUntil: this.breakerUntil > this.now().getTime() ? new Date(this.breakerUntil).toISOString() : null, lastError: this.lastError }
126
128
  }
127
129
 
@@ -130,7 +132,7 @@ export class JevClient {
130
132
  const key = resolveJevKey()
131
133
  if (!key) throw new JevError('jev_not_configured')
132
134
  if (this.breakerUntil > this.now().getTime()) throw new JevError('jev_breaker_open')
133
- if (this.usedToday() + estimate > dailyCap()) this.fail('jev_cap_reached', false)
135
+ if (this.usedToday() + estimate > this.cap()) this.fail('jev_cap_reached', false)
134
136
  let res: Response
135
137
  try {
136
138
  res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.timeout(JEV_LIMITS.timeoutMs),