@gotcos/glasses-server 6.66.0 → 6.70.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/CHANGELOG.md CHANGED
@@ -1,3 +1,85 @@
1
+ ## 6.70.0 — private test candidate
2
+
3
+ - Meeting Ask public web search works for ordinary public questions. 6.69.0 withheld every search tried in the Oct 10 live test: "Who is alt coin daily?", "What's the latest news on our competitor Acme?" and the follow-up "Run the web search". Server only; no contract change. Works with COS Control 0.5.280 and later and COS Glasses 6.10.623 and later; COS Control 0.5.282 and COS Glasses 6.10.624 add a model control to the Ask chat.
4
+ - Public intent is one rule (`publicIntent` in `server/lib/meeting-ask-web.ts`): a question word followed by is, are, was, were, does, did, many or much ("who is", "what's", "how many"), a public-information word (web, search, google, find, latest, news, CEO, founder, founded, company, website, followers, subscribers, definition, price and the 6.69.0 words), "known for", or "look ... up".
5
+ - my, our, we, us, client and customer no longer withhold a question. They are dropped from the query: "our client Acme" searches "Acme".
6
+ - A follow-up that names nothing ("Run the web search", "search that", "look it up", "Google it") may use words from the user's own last three questions in the same meeting, read from the journal. Only completed or failed free-form Asks count.
7
+ - Typed words may be joined or split, so "alt coin" admits "Altcoin".
8
+ - The privacy rule is unchanged: only words the user typed reach the web. Transcript turns, file and screenshot text, COS memory, earlier meetings and earlier answers never add a query word. A secret, including the mark redactSecrets leaves in a stored question, or a sensitive word (birthday, birthdate, address, phone, email, salary, medical, diagnosis, password, secret, confidential, wife, husband, daughter, son, family) in the question, the query or a borrowed question still withholds. Presets never search. Each Ask still gets at most two lookups and three model calls, the hook still allows one WebSearch, and only public URLs are kept.
9
+ - A withheld search says why and gives an example: a key or token, a personal detail, no public question, words you did not type, or nothing specific to search. 6.69.0 showed one generic line for all of them.
10
+ - An explicit web request is honored. When the question plainly asks for the web ("web search", "search the web", "on the web", "... online", google as a verb):
11
+ - if the answer model replies without searching the web, the server searches the user's own words once and asks again, with a notice;
12
+ - if the model's query added words the user did not type, the user's own words are searched instead, with a notice;
13
+ - if the user's words cannot be searched, the reason is shown and nothing is sent;
14
+ - a search request with no `searchScope` goes to the web; otherwise it goes to COS.
15
+ - The answer prompt now spells out `{"answer":"","searchQuery":"...","searchScope":"web"}` and says to build the query only from the user's words.
16
+ - Known limits: an implicit public question still searches only when the answer model asks; in the 6.69.0 field run Haiku answered "Who is alt coin daily?" from a screenshot instead. "look it up" and "search that" are not treated as explicit web requests, because they may mean COS. Sensitive words still withhold public questions that contain them ("Who is Masayoshi Son?").
17
+
18
+ ## 6.69.0 — private test candidate
19
+
20
+ - Meeting Ask, version 2. The server keeps each meeting's questions, answers, sources and file text in its own journal, apart from the meeting's Markdown, so a client can show them again after navigation or a restart. A Meeting assistant setting picks the answer model. Claude Haiku stays the default. A free-form question can also look in COS memory, earlier meetings and the public web. The four presets (`catch_up`, `summary`, `questions`, `actions`) read the current meeting only. Pairs with COS Control 0.5.280 and COS Glasses 6.10.623. Contract: `shared/meeting-ask.ts`.
21
+ - `GET /api/health` and `GET /api/models` add `capabilities.meetingAsk = { protocolVersion: 2, durableHistory: true, attachmentContext: true, webRetrieval }`. An older server leaves it out and answers 404 on `GET /api/meeting-ask/config`.
22
+ - New routes sit under `/api/meeting-ask`. All need the pairing token and all answer `no-store`. They are `GET` and `PUT config`, `POST requests` (202 `{ exchange }`), `GET requests/<id>`, `POST requests/<id>/cancel`, `GET history`, `PUT files`, `GET export` and `GET transcript`.
23
+ - Submit, cancel, history, files, export and transcript carry the server's `serverInstanceId`. A different one is 409 `server_instance_mismatch`. History is filtered by it.
24
+ - `GET transcript?sessionId&serverInstanceId` returns the live recording's turns as this server holds them, with secrets redacted. It is 404 `session_not_available` when the server holds none.
25
+ - The journal is durable and owned by the server.
26
+ - It lives in `<data>/meeting-ask-v2/`: one file per request, one file list per meeting, and the model setting. Folders are 0700 and files 0600. A symlinked folder is refused (`unsafe_store`). Each write is fsync'd, then renamed into place.
27
+ - The question is written before any model runs. If that write fails, the Ask is refused with 507 `journal_write_failed` and no model runs.
28
+ - States: `pending`, `running`, `completed`, `failed`, `cancelled`, `interrupted`. Once a request leaves `pending` or `running`, its state is final. A late answer cannot overwrite a cancel.
29
+ - The client chooses the request ID: 8 to 100 letters, digits, `_` or `-`. The same ID with the same payload returns the saved exchange and runs nothing, during a drain too. The same ID with a changed payload is 409 `request_id_conflict`.
30
+ - After a restart, a request still `pending` or `running` is marked `interrupted` (`server_restarted`) on the first journal read. It never runs again by itself. A retry is a new request whose `retryOf` names the old one. The old one must be in the same meeting and finished, or the retry is 409 `invalid_retry`.
31
+ - If the last write fails after an answer arrives, the answer stays in memory and reads `failed` with `journal_write_failed` and a notice to export. The model is not run again. The request keeps its lease and the Ask slot, so later Asks get `busy`.
32
+ - Limits: 2,000 saved requests or 200 MB of request files, then 507 `history_storage_limit`. Nothing is deleted to make room. 100 Asks a day across the server and 50 per meeting, by UTC date, then 429 `daily_request_limit`. A submit body is at most 850,000 bytes (413 `request_too_large`).
33
+ - The Meeting assistant setting chooses the model.
34
+ - `GET /api/meeting-ask/config` lists models with `available` and a `reason`. Claude offers `sonnet`, `haiku` and `opus`. Codex offers the models in its local model cache, or `gpt-5.6-terra` when the cache is empty. Cursor and Ollama are listed but cannot be selected (`meeting_adapter_not_qualified`). A missing CLI shows its own reason.
35
+ - `PUT /api/meeting-ask/config` `{ engine, model }` saves it in the journal folder, apart from other model settings. A model that is not available is 503 `model_unavailable`. Nothing falls back to another model.
36
+ - Each request is pinned to the model it was admitted with. Changing the setting later does not change an Ask already admitted.
37
+ - The answer runs in a fresh CLI process in an empty temporary folder. Claude runs with no tools, no MCP servers and hooks off, with output capped at 1,400 tokens and thinking off. Codex runs with its user config and rules ignored, its shell, hooks and web search off, and a read-only sandbox. A Codex run that reports any tool activity is rejected. Codex does not report the model it used, so the exchange shows `codex:<model> (requested)`.
38
+ - Each model call gets 45 seconds and the whole Ask gets 145 seconds. A stop sends SIGTERM to the process group, then SIGKILL after 1 second, and waits up to 4.5 seconds for proof that it exited. A running Ask that reaches 145 seconds or meets a drain ends `cancelled`.
39
+ - Every spawned model call and web search writes one row to the token audit, whatever its outcome: source `meeting-ask` or `meeting-ask-web`, the model, estimated input and answer size, and duration. 6.68.0 logged Live Ask through the lens-gist engine; the new runner had dropped that row.
40
+ - Retrieval starts from the current meeting.
41
+ - Only a free-form question may look elsewhere. Each Ask gets at most two lookups and three model calls, COS and web combined. The presets never search.
42
+ - A COS lookup returns up to 3 memories and 3 earlier meetings scoring 0.35 or more, leaving out the current meeting. Sources keep their memory ID or meeting record ID. These lookups now stop on cancel, and after 15 seconds.
43
+ - Public web: `webRetrieval` is true when the Claude CLI is installed. That is a binary check, not a sign-in or search check. The search runs in a separate Claude Haiku process that may call WebSearch once. A hook denies every other tool and any second search, and the server rejects a run that shows otherwise.
44
+ - A web search is sent only when the question asks for public information (words such as web, latest, official, definition or what is). Every query word must come from the question or a short list of connecting words. A private word (my, our, client, salary, address and similar) or a secret withholds it, with a notice.
45
+ - Results are one search summary plus observed result links, up to 7 sources, cited `[W1]`. A link is kept only if it is public http or https on port 80 or 443 with no credentials, and every address its host resolves to is public. Pages are not fetched. When an answer cites one result, the rest of that search comes with it.
46
+ - Sources are typed. Transcript turns keep their turn ID, time and session. Memories keep their memory ID and earlier meetings their record ID. Files keep their ID and revision, and web results their URL and retrieval time. A citation to an unknown ID shows `[source unavailable]`.
47
+ - Files give Ask more context.
48
+ - `PUT /api/meeting-ask/files` `{ sessionId, serverInstanceId, files }` adds or updates up to 50 files per call. A call is at most 500,000 bytes and a file at most 100,000 bytes of text. It never deletes; removal is an explicit `removed` status. States: `ready`, `pending`, `unsupported`, `failed`, `removed`, `secret-held`. Text that contains a secret is held back as `secret-held`.
49
+ - A meeting holds up to 100 files and 500,000 bytes of text (413 `file_context_limit`). The whole file store holds up to 2,000 meetings and 100 MB (507 `file_context_storage_limit`). An update that shrinks the store is still allowed.
50
+ - An Ask may reference up to 100 files, every file a meeting can register. By default it uses every file not removed. Each file's text and revision are copied into the exchange at admission, so a later edit affects later Asks only. A revision that no longer matches is 409 `file_revision_changed`.
51
+ - The model sees up to 16,000 characters of ready file text, cited `[F1]`. A file without usable text gets a notice instead.
52
+ - Ask work stays owned through updates and shutdown.
53
+ - Each admitted Ask, and each legacy Ask, holds its own `meeting_ask` maintenance lease until its provider process has exited and the result is written. The 202 response does not release it. If the exit cannot be confirmed, the lease is kept.
54
+ - During maintenance, a new Ask is refused with 503 `maintenance`, and setting changes and file updates are refused too (the general drain middleware may answer them first with `maintenance_drain_active`). A running Ask checks every 100 ms and stops when a drain begins. Status, history, export and cancel keep answering.
55
+ - One Ask runs at a time across the server, legacy and v2 together. A second is 429 `busy`.
56
+ - Shutdown stops a running Ask and waits up to 5.5 seconds, inside the 8-second exit limit.
57
+ - History and copy.
58
+ - `GET /api/meeting-ask/history?sessionId&serverInstanceId&offset&limit&order` answers `{ exchanges, nextOffset, files }`. It is oldest first by default, and `order=desc` gives newest first. `limit` is 1 to 100, default 50. `nextOffset` is null on the last page. Every accepted exchange stays available.
59
+ - A follow-up uses the meeting's last 4 completed answers from the journal for wording. The client sends no history.
60
+ - `GET /api/meeting-ask/export` returns Markdown: every exchange with its question, status, model, sources and notices, then the meeting's files. Citations carry their request ID (`[<requestId>:T1]`) so they stay unique across answers. A file revision an Ask used keeps its admitted text after it is removed or replaced, and its current status is noted beside it. Later revisions are included too. The transcript is not included; the client adds its own. Over 850,000 bytes is 413 `export_too_large`.
61
+ - Compatibility: `POST /api/meetings/live-ask` from 6.68.0 still works with the same request and response, Haiku only. It now shares the one-at-a-time limit with v2, takes the `meeting_ask` lease instead of the general request lease, stops when a drain begins, and runs through the new Meeting Ask runner. Its sources may now also carry `memoryId` or `recordId`. Clients built for 6.68.0 keep working. Durable history, files and the model choice need COS Control 0.5.280 or COS Glasses 6.10.623.
62
+ - Known limits in this candidate: each request record stores its full submitted transcript, so long meetings can reach the 200 MB cap well before 2,000 requests. A timeout or a drain ends `cancelled`, the same status as a user stop; only a user stop says `cancelled_by_user`.
63
+
64
+ ## 6.68.0 — private test candidate
65
+
66
+ - Add an authenticated, on-demand Live COS Ask endpoint for Control: Haiku answers from the current meeting snapshot, with optional bounded COS history searches for free-form questions, source citations, cancellation and no background model calls. Uses the existing isolated Claude CLI runner and stored sign-in.
67
+
68
+ ## 6.67.0 — 2026-10-09 — glasses pairing
69
+
70
+ - Pair COS Glasses with your Mac by scanning a code instead of pasting the pairing token. COS Control shows a QR and a short code; the phone claims it, and nothing happens until you press Allow on the Mac. Pairs with COS Glasses 6.10.621 and the COS Control pairing release. Contract: `docs/pairing-contract.md`.
71
+ - `GET /api/health` adds `capabilities.pairing = { version: 1 }`. An older server leaves it out, which is how the phone knows to say "Update COS on your Mac".
72
+ - `POST /api/pairing/code` (token, and only from this Mac) makes an 8-character code, good for 5 minutes and one use, plus the QR text `COS1/MAC/<CODE>/<host>:<port>[+…]/<EXP>`, which uses only characters a QR can pack tightly. Tailscale addresses come first. A Wi-Fi address is offered only when "Allow pairing on this Wi-Fi" is on, and that lasts 10 minutes. A new code cancels the old one and any claim waiting for Allow.
73
+ - `POST /api/pairing/claim` and `GET /api/pairing/claim/<nonce>` need no token. A claim is accepted over Tailscale, or over the home Wi-Fi only while that is allowed. It waits up to 60 seconds for Allow. The phone then reads the pairing token once; a second read says `delivered` without it. The poll must come from the same address as the claim, and keeps answering during an update so an allowed token is never lost.
74
+ - `GET /api/pairing/status` and `POST /api/pairing/decision` (token, and only from this Mac) are how Control shows "wants to pair: Allow / Deny" and turns green, which happens only when the new phone makes its first signed-in request.
75
+ - Limits: 5 claims a minute per address, 20 tries per code (then it locks until a new code). The limits use the real connection address, never `X-Forwarded-For`.
76
+ - Everything is kept in memory. A restart forgets every code (`unknown_code`, a new `bootId`). The pairing routes write no files, so they never hold up an update; during one they answer `draining`.
77
+ - Logs carry the address, the result and a 2-character hash of the code, never the code, the nonce, the token or the QR text.
78
+ - A rejected token now says "Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer." The `reason` stays `pairing_token_rejected`.
79
+ - The pairing token is still the only standing credential. See SECURITY.md.
80
+ - Compatibility: nothing changes for existing users. Pasting the pairing token still works, and older COS Control and COS Glasses builds keep working with this server. Scanning needs COS Glasses 6.10.621 and the COS Control release with the Glasses setup section. The QR needs Tailscale on both the Mac and the iPhone, or the 10-minute "Allow pairing on this Wi-Fi" switch at home.
81
+ - `docs/pairing-fixture.json` is a parity fixture the glasses app copies: the alphabet, real QR text, the reasons and the limits, checked against the server's code by its tests.
82
+
1
83
  ## 6.66.0 — 2026-10-08 — Managed voice setup and Haiku polish
2
84
 
3
85
  - 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.
package/SECURITY.md CHANGED
@@ -27,6 +27,22 @@ reproduction you have. Do not include real transcripts.
27
27
 
28
28
  - The pairing token (`X-Cos-Token`) is the only credential. Rotating it
29
29
  invalidates every display-stream ticket; there is no server-side ticket store.
30
+ - Pairing codes (6.67.0, `docs/pairing-contract.md`) are not credentials. A code
31
+ lives 5 minutes in memory, works once, is made only over loopback with the token,
32
+ and releases the pairing token only after someone presses Allow on the Mac, and
33
+ then only once, to the address that claimed it. Claims are accepted over Tailscale
34
+ (100.64.0.0/10), or a private Wi-Fi address only while the user has allowed it for
35
+ 10 minutes. Limits: 5 claims a minute per connection address, 20 tries per code.
36
+ Codes, nonces, tokens and QR text are never logged. The pairing token is still the
37
+ only standing credential.
38
+ - A device on your tailnet can lock a live pairing code by guessing at it 20 times.
39
+ This is accepted: COS Control says why and offers a new code, and a code already
40
+ claimed is never locked by later guesses.
41
+ - While "Allow pairing on this Wi-Fi" is on, the pairing token reaches the phone over
42
+ plain HTTP on that Wi-Fi, as the pasted token always has. Use it only at home; it
43
+ turns itself off after 10 minutes. Over Tailscale the traffic is encrypted.
44
+ - Behind `tailscale serve` or `funnel`, requests arrive from a loopback proxy, so
45
+ every claim is refused (`not_allowed_network`): pairing fails closed there.
30
46
  - `GET /api/display-stream` is public by design and returns lifecycle events
31
47
  only. Content requires a ticket or the token header. See
32
48
  `server/routes/display.ts` for the allowlist.
@@ -0,0 +1,118 @@
1
+ <!-- Copy of operations/personal/wk41_2026/CONTRACT_glasses_pairing_2026-10-09.md in the COS repo, the single source of truth for server 6.67.0, glasses 6.10.621 and the Control pairing branch. Server notes on what the contract left open are at the end. -->
2
+
3
+ # Contract: glasses pairing (server 6.67.0, glasses 6.10.621, Control branch for Codex)
4
+
5
+ This is the single source of truth for the three builders. Design and reasons: `PLAN_glasses_onboarding_quick_connect_2026-10-08.md`, sections v2 and v4.
6
+
7
+ **Miles, 2026-10-09 07:23:** "We'll use the next version release as the canary. We'll start with Miles network, he should see the QR with the newer Activity setup view that contains the QR code for scanning."
8
+
9
+ **Scope of this release: pairing only.** Durability (Part C), approve-from-glasses (Part B) and the tour come in later releases.
10
+
11
+ ## Capability
12
+ - `GET /api/health` adds `capabilities.pairing = { version: 1 }`.
13
+ - Clients check it before any pairing call. When it is missing, the phone says "Update COS on your Mac (server 6.67 or newer)" and Control shows "Update the server".
14
+
15
+ ## Code and QR grammar (the server builds both, clients only parse)
16
+ - **Code:** 8 characters, Crockford base32 (`0123456789ABCDEFGHJKMNPQRSTVWXYZ`).
17
+ - Normalize input: uppercase it, map O→0, I and L→1, and strip spaces and dashes.
18
+ - Display it as `XXXX-XXXX`.
19
+ - **QR text:** `COS1/MAC/<CODE>/<host>:<port>[+<host>:<port>]/<EXP>`
20
+ - All uppercase alphanumeric plus `/ : + .`, so the QR stays at version 3 or 4. (Changed from `,` after QA round 1: a comma is not in the QR alphanumeric set. The server writes `+`; clients accept `+` and `,`.)
21
+ - `<EXP>` is the expiry, in unix seconds, written in base36 uppercase.
22
+ - Hosts are IPv4 only, http. Tailscale (100.64.0.0/10) comes first; a LAN address (RFC1918) is included only while LAN pairing is armed.
23
+ - `MAC` is the kind. `CLOUD` is reserved for later. Any other kind, or a version other than `COS1`, is refused with "Update COS to use this code".
24
+ - Clients refuse hosts outside 100.64/10 and RFC1918.
25
+
26
+ ## Server routes (6.67.0)
27
+ All under `/api/pairing`. Every response carries a `reason` string on failure. The reasons are:
28
+ `expired`, `used`, `locked`, `rate_limited`, `not_loopback`, `not_allowed_network`, `unknown_code`, `denied`, `draining`, `bad_request`.
29
+
30
+ | Route | Auth | Body or params | 200/202 response |
31
+ |---|---|---|---|
32
+ | `POST /pairing/code` | token **and** loopback socket (`req.socket.remoteAddress`, both forms; never `req.ip` or XFF) | `{ allowLan?: boolean }` (`allowLan` arms LAN claims for 10 minutes) | `{ code, display, qr, expiresAt, bootId, hosts:[{host,port,kind:'tailscale'|'lan'}], lanArmedUntil|null }`. A new code cancels the previous one and any pending claim. |
33
+ | `POST /pairing/claim` | **public**, an exact `POST` match in `isPublicApiRequest`, with its own JSON parser capped at 1 KB | `{ code, nonce }` (`nonce` is 16 to 64 characters from `[A-Za-z0-9_-]`, made by the phone) | 202 `{ pending: true }`. Idempotent for the same nonce and code. |
34
+ | `GET /pairing/claim/:nonce` | **public**, and the caller's socket IP must equal the claim's IP | | `{ state: 'pending'|'allowed'|'denied'|'expired'|'delivered', token?, serverName? }`. The token is returned **once** (the `allowed` state moves to `delivered`). A later read returns `delivered` with no token. |
35
+ | `GET /pairing/status` | token and loopback | | `{ code: {display, expiresAt}|null, pending: {nonce, ip, at}|null, lastClaim: {ip, at, allowed}|null, firstAuthAfterClaimAt|null, lanArmedUntil|null, bootId }` |
36
+ | `POST /pairing/decision` | token and loopback | `{ nonce, allow: boolean }` | `{ ok: true }` |
37
+
38
+ ### Rules
39
+ - **Network:** a claim is accepted from 100.64/10. It is accepted from RFC1918 only while LAN is armed. Anything else gets `not_allowed_network`. This check sits on top of the existing network allowlist.
40
+ - **Code lifetime:** 5 minutes, single use (the first successful claim moves it to `used`).
41
+ - **Pending claims:** one at a time. A pending claim waits up to 60 s for a decision, then becomes `expired`. A second claim while one is pending is refused with `locked`, and that does not cancel the first.
42
+ - **Limits:**
43
+ - 5 claim attempts per minute per socket IP, then `rate_limited`.
44
+ - 20 attempts per code, then the code is `locked` until a new code is made.
45
+ - The limiter keys on the socket IP. Trust-proxy is never enabled.
46
+ - **State:** in memory only. A restart loses codes, so a claim with an old code gets `unknown_code`, and `bootId` changes.
47
+ - **Drain:** the pairing routes are exempt from the mutation lease, since they write no files. When shutting down, `code`, `claim`, `status` and `decision` return 503 `draining`. The poll `GET /pairing/claim/:nonce` is still served (QA round 1), so a token already allowed reaches the phone even if the update gate closes before the next poll.
48
+ - **First authenticated call:** when a request passes `requireApiToken` and its socket IP equals an allowed `lastClaim.ip`, stamp `firstAuthAfterClaimAt` once.
49
+ - **Logging:** never the code, nonce, token or QR text. Log the IP, the result, and the first 2 characters of a sha256 of the code. A test must fail if any of those values appear in log output.
50
+ - **Docs:**
51
+ - SECURITY.md: the pairing token is still the only standing credential; pairing codes are short-lived and single use.
52
+ - `docs/pairing-contract.md`: a copy of this file.
53
+ - Change the 401 copy to "Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer." (QA round 1: older glasses show it word for word.) The `reason` value stays `pairing_token_rejected`.
54
+
55
+ ## Glasses 6.10.621
56
+ - **Parser:** `src/lib/pair-code.ts` parses the QR grammar, normalizes codes and orders hosts. It is unit tested and exposed to the ES5 wizard as `window.__COS_PAIR`.
57
+ - **Scan entry points:**
58
+ - the wizard Start screen (first option, "Scan code on your Mac")
59
+ - `setup-step1` (the reauth path)
60
+ - the Settings overlay
61
+ - the local starter banner's Connect Mac
62
+ - **Ways in:**
63
+ - **Scan:** `window._evenBridge.captureImageFromCamera()`
64
+ - **Use a screenshot:** `pickImageFromAlbum()`
65
+ - **Type the code:** the code plus the Mac address
66
+ - Scan and screenshot are shown only when the bridge method is a function.
67
+ - **Decoding:** jsQR, decoding at the native width capped at 1920, then a second pass at 1280. A miss shows "Move closer so the code fills your screen".
68
+ - **Flow:**
69
+ 1. Check health on the first host that answers (try hosts in order, 3 s each).
70
+ 2. Make a nonce, then claim.
71
+ 3. Poll `GET /pairing/claim/:nonce` every 1.5 s for up to 70 s. The screen and the lens say "Waiting for Allow on your Mac".
72
+ 4. When the token arrives, write `cos_server_url = http://host:port` and `cos_api_token`, then run the existing `fetchAuthenticatedTarget`, then `finishConnectedSetup`, exactly as the IP form does.
73
+ 5. Keep the token in memory and retry if `/api/models` fails.
74
+ - **Re-pairing:** when a committed target already exists, ask "Replace <old host> with <host>?" first.
75
+ - **Lens lines:** "Pairing with your Mac…", "Waiting for Allow on your Mac", "Paired", "Code expired: show a new one", "Mac is updating". Each fits 6 lines and the width, using only drawable glyphs. isWearing is never used.
76
+ - **Manifest:** `camera` and `album` go in app.json **and** app-hub.json. Update `verify-timer-quarantine-build.mjs` and the `marketplace-hardening` pin. Strip URL-bearing comments from the jsQR bundle. Add a `THIRD_PARTY_NOTICES` entry.
77
+ - **Version touchpoints:** COS_VERSION, package.json, package-lock (both fields), CHANGELOG, the index.html changelog, app.json, app-hub.json, and the Hub receipt.
78
+
79
+ ## Control (branch off `origin/codex/whisper-runtime` 601a944, released by Codex)
80
+ - **New helper verbs:**
81
+ - `tailscale-status` runs the Tailscale bundle binary, `/Applications/Tailscale.app/Contents/MacOS/tailscale`, with **`SHLVL=1`** in the child's environment. It returns `{installed, running, selfIPv4, selfDNS, peers:[{os, dns, ipv4, online, sameUser}]}`, read from `status --json`. Use `Self.UserID` to work out `sameUser`; never use `HostName`.
82
+ - `tailscale-whois <ip>` returns `{node, os, user}`.
83
+ - `pairing-code [--allow-lan]`, `pairing-status` and `pairing-decision <nonce> allow|deny` call the server routes above over loopback with the token.
84
+ - **Setup guide, new "Glasses" section.** It is **not counted** in Finish setup, and it replaces `GlassesGuideRow`. It has three rows:
85
+ 1. **Tailscale on this Mac:**
86
+ - not installed: Get Tailscale (https://tailscale.com/download/mac)
87
+ - signed out: Open Tailscale
88
+ - running: green, with the address
89
+ 2. **Tailscale on your iPhone:**
90
+ - shows a QR for `https://apps.apple.com/app/tailscale/id1470499037`
91
+ - copy: "Sign in with the same account as this Mac"
92
+ - turns green when a same-user iOS peer is online, naming it ("iphone-15-pro-max is on your tailnet")
93
+ 3. **Connect your glasses:**
94
+ - Shown only when `capabilities.pairing` is present.
95
+ - The QR is drawn with CoreImage `CIQRCodeGenerator`, correction level Q, from the server's `qr` string, at least 220 pt. Show `display` and the first host as text, with the copy "Hold your phone so the code fills the screen".
96
+ - Re-mint only while the pane is visible: when the code expires or `bootId` changes, and on **Show a new code**.
97
+ - When `pending` appears, show the **Allow / Deny** card: "<whois node> (<user>) wants to pair". For a non-tailnet IP: "Device on Wi-Fi <ip>".
98
+ - It turns green on `firstAuthAfterClaimAt`, showing "Paired: <node>, <time>".
99
+ - "Allow pairing on this Wi-Fi (home only)" is a toggle (`--allow-lan`), off by default.
100
+ - **Plumbing:** add new Swift files to every `Tests/run.sh` source list. Every compile goes through `Tests/compile-guard.sh`, one at a time.
101
+ - **Reset pairing** (token rotation): **not in this release.** It ships with Part B.
102
+
103
+ ## Server 6.67.0: what the contract left open, as built
104
+
105
+ - **Times** in JSON (`expiresAt`, `lanArmedUntil`, `at`, `firstAuthAfterClaimAt`) are ISO 8601 strings, or null. Only the QR `<EXP>` is base36 unix seconds.
106
+ - **No host:** when the Mac has no Tailscale IPv4 and LAN is not armed, `POST /pairing/code` still mints, with `hosts: []` and `qr: null`, so Control can say why instead of drawing a QR no phone can use.
107
+ - **Hosts:** at most 3, Tailscale first. LAN addresses come only from RFC1918 on interfaces a phone can reach (never `utun` VPNs that are not Tailscale, `bridge`, `awdl`, `llw`, `vmnet`, `vboxnet`).
108
+ - **allowLan:** `true` arms LAN for 10 minutes; a mint without it disarms LAN, so Control's toggle is the source of truth.
109
+ - **Failures** are `{ reason, message }`: 400 `bad_request`, 403 `not_loopback` / `not_allowed_network` / `denied`, 404 `unknown_code`, 409 `used` / `locked`, 410 `expired`, 429 `rate_limited` (with `Retry-After`), 503 `draining`.
110
+ - **Poll:** an unknown nonce (for example after a restart) is 404 `unknown_code`; a poll from a different socket IP is 403 `not_allowed_network` and does not spend the token. `serverName` comes with the token and with `delivered`. Only a real GET may read the token: a HEAD (which needs the token header to get past the gate at all) is answered 405.
111
+ - **Status:** `code` is present while the current code is unexpired and carries an extra `state: 'active'|'used'|'locked'`.
112
+ - **Decision** on a nonce that is not pending: 404 `unknown_code`, 410 `expired`, or 409 `used` (already decided).
113
+ - **Per-code limit:** every claim attempt while a code is active counts against it, right code or wrong; the 21st attempt locks it. Once a code is `used`, later attempts neither count nor lock it: the right code answers `locked` while its claim waits for Allow and `used` after, a wrong one `unknown_code`. Codes are compared in constant time.
114
+ - **firstAuthAfterClaimAt** is stamped only after the allowed claim's token was delivered, on the first token-authenticated request from that claim's IP. A new Allow clears it.
115
+ - **Draining** means the server is shutting down or the maintenance gate is closed (an update in progress, including the startup gate before Control releases it). The poll is never refused for draining.
116
+ - **Loopback** accepts any 127.0.0.0/8, `::1` and `::ffff:127.x` socket at the pairing check, but the server's global network allowlist (unchanged) admits only `127.0.0.1` and `::1` exactly, so another 127.x address is refused before pairing sees it.
117
+ - **QR alphabet:** hosts are joined with `+`, which is in the QR alphanumeric set (`0-9 A-Z space $ % * + - . / :`), so every QR the server writes can be encoded in alphanumeric mode.
118
+ - **Parity fixture:** `docs/pairing-fixture.json` holds the alphabet, nonce pattern, QR pattern and charset, normalization cases, two real `buildPairingQr` outputs (one and two hosts), the reason list with statuses and messages, the poll states and the limits. `server/lib/glasses-pairing.test.ts` recomputes every value, so it fails if the fixture drifts. Clients copy it; nobody edits it by hand.
@@ -0,0 +1,94 @@
1
+ {
2
+ "$comment": "Server-generated parity fixture for glasses pairing (docs/pairing-contract.md). server/lib/glasses-pairing.test.ts fails if any value here stops matching what the server produces. Clients copy it; never hand-edit it.",
3
+ "protocolVersion": 1,
4
+ "capability": {
5
+ "pairing": {
6
+ "version": 1
7
+ }
8
+ },
9
+ "alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
10
+ "codeLength": 8,
11
+ "noncePattern": "^[A-Za-z0-9_-]{16,64}$",
12
+ "qrHostSeparator": "+",
13
+ "qrPattern": "^COS1\\/MAC\\/[0-9A-HJKMNP-TV-Z]{8}\\/(?:\\d{1,3}(?:\\.\\d{1,3}){3}:\\d{1,5})(?:\\+\\d{1,3}(?:\\.\\d{1,3}){3}:\\d{1,5})*\\/[0-9A-Z]+$",
14
+ "qrCharset": "^[0-9A-Z/:+.]+$",
15
+ "normalize": {
16
+ "abcd-efgh": "ABCDEFGH",
17
+ " oOil LI10 ": "00111110",
18
+ "7k3m 9pqr": "7K3M9PQR",
19
+ "ABCD-EFGU": null,
20
+ "ABCD-EFG": null
21
+ },
22
+ "examples": [
23
+ {
24
+ "code": "7K3M9PQR",
25
+ "display": "7K3M-9PQR",
26
+ "expiresAtMs": 1791547500000,
27
+ "hosts": [
28
+ {
29
+ "host": "100.100.10.20",
30
+ "port": 3141,
31
+ "kind": "tailscale"
32
+ }
33
+ ],
34
+ "qr": "COS1/MAC/7K3M9PQR/100.100.10.20:3141/TMN2WC"
35
+ },
36
+ {
37
+ "code": "7K3M9PQR",
38
+ "display": "7K3M-9PQR",
39
+ "expiresAtMs": 1791547500000,
40
+ "hosts": [
41
+ {
42
+ "host": "100.100.10.20",
43
+ "port": 3141,
44
+ "kind": "tailscale"
45
+ },
46
+ {
47
+ "host": "192.168.1.20",
48
+ "port": 3141,
49
+ "kind": "lan"
50
+ }
51
+ ],
52
+ "qr": "COS1/MAC/7K3M9PQR/100.100.10.20:3141+192.168.1.20:3141/TMN2WC"
53
+ }
54
+ ],
55
+ "reasons": {
56
+ "bad_request": 400,
57
+ "not_loopback": 403,
58
+ "not_allowed_network": 403,
59
+ "denied": 403,
60
+ "unknown_code": 404,
61
+ "used": 409,
62
+ "locked": 409,
63
+ "expired": 410,
64
+ "rate_limited": 429,
65
+ "draining": 503
66
+ },
67
+ "reasonMessages": {
68
+ "bad_request": "That pairing request was not understood. Scan the code again.",
69
+ "not_loopback": "Pairing codes are made only by COS Control on this Mac.",
70
+ "not_allowed_network": "Connect over Tailscale, or turn on \"Allow pairing on this Wi-Fi\" in COS Control.",
71
+ "denied": "Pairing was denied on the Mac.",
72
+ "unknown_code": "This code is not current. Show a new one in COS Control.",
73
+ "used": "This code was already used. Show a new one in COS Control.",
74
+ "locked": "Too many tries, or another device is waiting. Show a new code in COS Control.",
75
+ "expired": "Code expired: show a new one in COS Control.",
76
+ "rate_limited": "Too many tries. Wait a minute and try again.",
77
+ "draining": "Your Mac is updating. Try again in a minute."
78
+ },
79
+ "pollStates": [
80
+ "pending",
81
+ "allowed",
82
+ "denied",
83
+ "expired",
84
+ "delivered"
85
+ ],
86
+ "limits": {
87
+ "codeTtlMs": 300000,
88
+ "pendingTtlMs": 60000,
89
+ "lanArmMs": 600000,
90
+ "claimsPerIpPerMinute": 5,
91
+ "attemptsPerCode": 20,
92
+ "maxQrHosts": 3
93
+ }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.66.0",
3
+ "version": "6.70.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": {
@@ -41,12 +41,14 @@
41
41
  "shared",
42
42
  "!server/**/*.test.ts",
43
43
  "!server/scripts/engine-canary.ts",
44
+ "!server/scripts/live-cos-ask-canary.ts",
44
45
  "!server/scripts/work-review-candidate.ts",
45
46
  "!server/scripts/work-hardening-fixture.ts",
46
47
  "!server/scripts/work-review-mutation-gate.mjs",
47
48
  "!server/scripts/work-intake-mutation-gate.mjs",
48
49
  "!server/scripts/work-progress-mutation-gate.mjs",
49
50
  "!server/scripts/work-search-mutation-gate.mjs",
51
+ "!server/scripts/pairing-mutation-gate.mjs",
50
52
  "!server/scripts/away-hold-mutation-gate.mjs",
51
53
  "!server/scripts/nemotron-mutation-gate.mjs",
52
54
  "!server/scripts/merged-scribe-mutation-gate.mjs",
@@ -78,6 +80,8 @@
78
80
  "!server/scripts/build-work-task-runtime.py",
79
81
  "bin/hooks/cos-cursor-observer.cjs",
80
82
  "docs/provider-parity.md",
83
+ "docs/pairing-contract.md",
84
+ "docs/pairing-fixture.json",
81
85
  "bin/whisper-runtime.cjs",
82
86
  "bin/whisper-runtime.d.cts"
83
87
  ],
package/server/index.ts CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { createServer as createHttpsServer } from 'node:https'
9
9
  import { createServer as createHttpServer } from 'node:http'
10
10
  import { readFileSync, existsSync } from 'node:fs'
11
- import { networkInterfaces, homedir } from 'node:os'
11
+ import { networkInterfaces, homedir, hostname } from 'node:os'
12
12
  import { join } from 'node:path'
13
13
  import { execSync } from 'node:child_process'
14
14
  import { randomBytes, randomUUID } from 'node:crypto'
@@ -104,6 +104,9 @@ import { glossaryRouter } from './routes/glossary.js'
104
104
  import { handoffsRouter } from './routes/handoffs.js'
105
105
  import { recoveryRouter } from './routes/recovery.js'
106
106
  import { promptEditRouter } from './routes/prompt-edit.js'
107
+ import { liveCosAskRouter } from './routes/live-cos-ask.js'
108
+ import { meetingAskRouter } from './routes/meeting-ask.js'
109
+ import { meetingAskRuntime } from './lib/meeting-ask-runtime.js'
107
110
  import { lensGistRouter } from './routes/lens-gist.js'
108
111
  import { bookmarksRouter } from './routes/bookmarks.js'
109
112
  import { setWeatherPersistence, welcomeContextRouter } from './routes/welcome-context.js'
@@ -162,9 +165,12 @@ import {
162
165
  import {
163
166
  isAllowedNetworkIp,
164
167
  isAllowedNetworkOrigin,
165
- isTailscaleIpv4,
168
+ isLoopbackSocket,
169
+ reachableIpv4Addresses,
166
170
  } from './lib/network-policy.js'
167
171
  import { requireApiToken } from './lib/api-auth.js'
172
+ import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
173
+ import { createPairingRouter } from './routes/pairing.js'
168
174
  import { isManagedRuntime } from './lib/managed-runtime.js'
169
175
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
170
176
  import {
@@ -317,7 +323,27 @@ if (threadAttachEnabled()) {
317
323
  // authenticated /tts/prepare for native audio players) and
318
324
  // /display-stream/<exp>.<hmac> GET/HEAD (minted on authenticated /api/models for
319
325
  // EventSource, which cannot set X-Cos-Token either). See api-auth.ts.
320
- app.use('/api', requireApiToken(API_TOKEN))
326
+ // 6.67.0 glasses pairing state: in memory only, so a restart loses every code and
327
+ // claim and `bootId` changes (docs/pairing-contract.md).
328
+ const pairingState = new PairingState({
329
+ token: () => API_TOKEN,
330
+ // The Mac's name as the phone shows it ("Paired with <name>").
331
+ serverName: () => hostname().replace(/\.local$/i, ''),
332
+ hosts: lanArmed => pairingHostsFrom(reachableIpv4Addresses(networkInterfaces()), PORT, lanArmed),
333
+ })
334
+ app.use('/api', requireApiToken(API_TOKEN, {
335
+ onAuthenticated: req => pairingState.noteAuthenticated(req.socket?.remoteAddress),
336
+ }))
337
+
338
+ // Pairing routes sit HERE on purpose: after the token gate (mint, status and decision
339
+ // need it; the claim and its poll are exact public doors in api-auth.ts), and BEFORE the
340
+ // mutation-lease catch-all and the global 10 MB parser. They write no files, so they
341
+ // never hold the lease, and each parses its own body at 1 KB. During a drain or
342
+ // shutdown they answer 503 `draining`.
343
+ app.use('/api', createPairingRouter({
344
+ state: () => pairingState,
345
+ isDraining: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
346
+ }))
321
347
 
322
348
  // Fail-closed catch-all for mutation routes that do not own a more specific
323
349
  // lifecycle lease below. This closes the admission/drain race for secondary
@@ -368,6 +394,9 @@ app.use('/api', (req, res, next) => {
368
394
  // the request lease. It refuses on its own while admissions are closed, and a queued
369
395
  // chain checks again before each engine (routes/lens-gist.ts, lib/lens-gist.ts).
370
396
  || req.path === '/lens-gist'
397
+ || req.path === '/meetings/live-ask'
398
+ || req.path === '/meeting-ask/requests'
399
+ || /^\/meeting-ask\/requests\/[A-Za-z0-9_-]+\/cancel$/.test(req.path)
371
400
  || req.path.startsWith('/maintenance/drain')
372
401
  if (lifecycleOwned) return next()
373
402
 
@@ -375,9 +404,7 @@ app.use('/api', (req, res, next) => {
375
404
  // still closed. Permit only its two bounded loopback proofs, and only with
376
405
  // the controller-held operation receipt. Normal phone/LAN admissions stay
377
406
  // closed throughout the update.
378
- const address = req.socket.remoteAddress ?? ''
379
- const loopback = address === '::1' || address === '127.0.0.1'
380
- || address.startsWith('127.') || address.startsWith('::ffff:127.')
407
+ const loopback = isLoopbackSocket(req)
381
408
  const controllerProofPath = req.path === '/diagnostics/provider-proof'
382
409
  || req.path === '/tts/prepare'
383
410
  const controllerProof = loopback && controllerProofPath
@@ -1072,6 +1099,8 @@ app.use('/api', handoffsRouter)
1072
1099
  app.use('/api', recoveryRouter)
1073
1100
  app.use('/api', promptEditRouter)
1074
1101
  app.use('/api', lensGistRouter)
1102
+ app.use('/api', liveCosAskRouter)
1103
+ app.use('/api', meetingAskRouter)
1075
1104
  app.use('/api', bookmarksRouter)
1076
1105
  // 6.63.1: the last good weather and place survive a restart (<data>/welcome-weather.json).
1077
1106
  setWeatherPersistence({
@@ -1110,6 +1139,7 @@ async function gracefulShutdown(): Promise<void> {
1110
1139
  gracefulShutdownStarted = true
1111
1140
  const forceExit = setTimeout(() => process.exit(1), 8_000)
1112
1141
  forceExit.unref?.()
1142
+ await meetingAskRuntime.shutdown()
1113
1143
  stopMorningBriefScheduler()
1114
1144
  await workReviewRuntime?.close()
1115
1145
  workIntakeStore?.close()
@@ -1225,17 +1255,11 @@ listenRequiredServers(listeners).then(() => {
1225
1255
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
1226
1256
  // not paste-able — enumerate real interfaces and label the Tailscale one.
1227
1257
  try {
1228
- const nets = networkInterfaces()
1229
- const addrs: Array<{ ip: string; label: string }> = []
1230
- for (const [name, infos] of Object.entries(nets)) {
1231
- for (const info of infos ?? []) {
1232
- if (info.family !== 'IPv4' || info.internal) continue
1233
- const isTailscale = isTailscaleIpv4(info.address) || name.startsWith('tailscale')
1234
- addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
1235
- }
1236
- }
1258
+ const addrs = reachableIpv4Addresses(networkInterfaces()).map(entry => ({
1259
+ ip: entry.address,
1260
+ label: entry.kind === 'tailscale' ? 'Tailscale — works from anywhere' : `${entry.name} — same Wi-Fi only`,
1261
+ }))
1237
1262
  if (addrs.length > 0) {
1238
- addrs.sort((a, b) => Number(b.label.startsWith('Tailscale')) - Number(a.label.startsWith('Tailscale')))
1239
1263
  console.log('')
1240
1264
  console.log('[COS API] Server URL for the COS Glasses app:')
1241
1265
  for (const a of addrs) console.log(`[COS API] http://${a.ip}:${PORT} (${a.label})`)
@@ -1,5 +1,6 @@
1
1
  import type { RequestHandler } from 'express'
2
2
  import { timingSafeTokenEqual } from './token-auth.js'
3
+ import { PAIRING_NONCE_BODY } from './glasses-pairing.js'
3
4
 
4
5
  // Recovery/setup clients need these availability surfaces before they have a
5
6
  // usable token. Keep private provider state and every mutation route out.
@@ -34,24 +35,48 @@ const TTS_PLAYBACK_CAPABILITY_PATH = /^\/tts\/play\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-
34
35
  // callers that can send one; the ticket exists only for the ones that cannot.
35
36
  const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64}$/
36
37
 
38
+ // 6.67.0 glasses pairing (docs/pairing-contract.md). Exactly two public doors, each
39
+ // matched on METHOD and path, never through the method-agnostic set above:
40
+ // - POST /pairing/claim: a phone with no token yet presents the short-lived code.
41
+ // - GET /pairing/claim/<nonce>: the same phone polls for the decision. The route binds
42
+ // the poll to the claim's socket IP, and the token is released only after Allow.
43
+ // Minting, status and decisions stay behind the token AND a loopback socket.
44
+ const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
45
+
37
46
  export function isPublicApiRequest(method: string, path: string): boolean {
38
47
  if (PUBLIC_API_PATHS.has(path)) return true
48
+ if (method === 'POST' && path === '/pairing/claim') return true
49
+ if (method === 'GET' && PAIRING_POLL_PATH.test(path)) return true
39
50
  if (method !== 'GET' && method !== 'HEAD') return false
40
51
  return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
41
52
  || DISPLAY_STREAM_CAPABILITY_PATH.test(path)
42
53
  }
43
54
 
55
+ export interface RequireApiTokenOptions {
56
+ /**
57
+ * Called once per request that passed the token check (never for a public path).
58
+ * Pairing uses it to stamp `firstAuthAfterClaimAt`. A throw here is swallowed: an
59
+ * observer must never turn an authenticated request into a failure.
60
+ */
61
+ onAuthenticated?: (req: Parameters<RequestHandler>[0]) => void
62
+ }
63
+
44
64
  /** Global /api authentication boundary. Mount before all body parsers. */
45
- export function requireApiToken(apiToken: string): RequestHandler {
65
+ export function requireApiToken(apiToken: string, options: RequireApiTokenOptions = {}): RequestHandler {
46
66
  return (req, res, next) => {
47
67
  if (isPublicApiRequest(req.method, req.path)) return next()
48
68
  if (!timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) {
49
69
  return res.status(401).json({
50
70
  error: 'unauthorized',
71
+ // Stable on purpose: clients key off this value, not the message.
51
72
  reason: 'pairing_token_rejected',
52
- message: 'In COS Control choose Copy Pairing Token, then paste the complete value into COS Glasses.',
73
+ // Older COS Glasses builds, which cannot scan, show this message word for word.
74
+ message: 'Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer.',
53
75
  })
54
76
  }
77
+ if (options.onAuthenticated) {
78
+ try { options.onAuthenticated(req) } catch { /* observer only */ }
79
+ }
55
80
  next()
56
81
  }
57
82
  }
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  import { execFile } from 'node:child_process'
11
+ import { runBoundedSearch } from './meeting-ask-search-process.js'
12
+ import { AskProcessError } from './meeting-ask-runner.js'
11
13
  import { existsSync, readFileSync } from 'node:fs'
12
14
  import { resolve } from 'node:path'
13
15
  import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
@@ -251,13 +253,31 @@ function semanticHitFromMemory(raw: MemorySemanticRaw): ContextSearchHit | null
251
253
  }
252
254
  }
253
255
 
254
- export function semanticSearchMemories(query: string, limit = 20): Promise<{
256
+ export function semanticSearchMemories(query: string, limit = 20, signal?: AbortSignal): Promise<{
255
257
  hits: ContextSearchHit[]
256
258
  reason?: string
257
259
  }> {
258
260
  const available = memorySemanticAvailable()
259
261
  if (!available.ok) return Promise.resolve({ hits: [], reason: available.reason })
260
262
  const script = resolve(COS_SCRIPTS_DIR!, MEMORY_SEMANTIC_SCRIPT)
263
+ if (signal) {
264
+ return runBoundedSearch(PYTHON_BIN!, memorySemanticArgs(query, limit, script), COS_SCRIPTS_DIR!, signal)
265
+ .then(stdout => {
266
+ try {
267
+ const parsed = JSON.parse(stdout)
268
+ const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed.results) ? parsed.results : []
269
+ return { hits: rows.flatMap((row: MemorySemanticRaw) => {
270
+ const hit = semanticHitFromMemory(row)
271
+ return hit ? [hit] : []
272
+ }) }
273
+ } catch { return { hits: [], reason: 'memory_parse_error' } }
274
+ })
275
+ .catch(error => {
276
+ if (error instanceof AskProcessError) throw error
277
+ return { hits: [], reason: 'no_memory_embeddings' }
278
+ })
279
+ }
280
+
261
281
  return new Promise(resolvePromise => {
262
282
  execFile(
263
283
  PYTHON_BIN!,
@@ -317,11 +337,11 @@ export function mergeContextSearchHits(
317
337
  .slice(0, Math.max(1, Math.min(limit, 50)))
318
338
  }
319
339
 
320
- export async function searchMemories(options: { query: string; limit?: number }): Promise<ContextSearchResult> {
340
+ export async function searchMemories(options: { query: string; limit?: number; signal?: AbortSignal }): Promise<ContextSearchResult> {
321
341
  const query = options.query.trim()
322
342
  const limit = Math.max(1, Math.min(options.limit ?? 20, 50))
323
343
  const keywordHits = keywordSearchMemories(query)
324
- const semantic = await semanticSearchMemories(query, limit)
344
+ const semantic = await semanticSearchMemories(query, limit, options.signal)
325
345
  return {
326
346
  hits: mergeContextSearchHits(keywordHits, semantic.hits, limit),
327
347
  keywordCount: keywordHits.length,
@@ -331,7 +351,7 @@ export async function searchMemories(options: { query: string; limit?: number })
331
351
  }
332
352
  }
333
353
 
334
- export async function searchThreads(options: { query: string; limit?: number }): Promise<ContextSearchResult> {
354
+ export async function searchThreads(options: { query: string; limit?: number; signal?: AbortSignal }): Promise<ContextSearchResult> {
335
355
  const query = options.query.trim()
336
356
  const limit = Math.max(1, Math.min(options.limit ?? 20, 50))
337
357
  const keywordHits = keywordSearchThreads(query)