@gotcos/glasses-server 6.94.2 → 6.94.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * What may leave this Mac about a session, and in what shape (the "find" route, Control 0.5.363).
3
+ *
4
+ * Every string a decision model sees, and every string the route hands back as `judged_text`, goes through
5
+ * `redactForModel`: READ a bounded window, REDACT it, then CUT. The order is the point. The list titles a session by
6
+ * cutting its first line at 80 characters BEFORE any redaction, so a key that straddles the cut leaves its first half
7
+ * unmasked (validated 2026-11-01: `OPENAI_API_KEY=abc123` cut after `abc` is no longer a recognizable pair). Here the
8
+ * pair is masked whole while the window still holds all of it, and only then does the cut fall.
9
+ *
10
+ * Steps, in order:
11
+ * 1. Read: lone surrogates, control and bidi characters out, NFKC (a full-width `john@ex…` becomes a plain address
12
+ * the next steps can see), one line, the first SCAN code points.
13
+ * 1b. Leading attachments out of the prompt and the title (`@"path"`, `[Image: source: path]`, a bare path), so the 150 characters
14
+ * start at the words typed.
15
+ * 2. This module's own rules for what mask does not know: home-folder paths, not every file path (/Users/<name>/…, /home, ~/, file://, C:\Users, /Volumes, /mnt/c)
16
+ * as `[path]`, links to an IP address or localhost with a port or path as `[link]` (bare domains stay), phone numbers, card numbers, US social security numbers, short
17
+ * `pk_` and `rk_` keys, base64 that carries `+` or `/` (first, because mask's long-run rule stops at a `/` and would leave
18
+ * the tail of a run), and a credential word in a few other languages.
19
+ * 3. SetupAssistant.mask, ported: key shapes, then `key: value` and `"key": "value"` pairs whose key names a credential,
20
+ * then long opaque runs. Control masks the same way before it draws a hit, so both sides agree on what a key is.
21
+ * 4. work-search's `searchOptionText`: the server's secret redaction, markdown links to their label, URLs, email
22
+ * addresses and opaque ids out. Its own slice at 300 UTF-16 units never comes into play for the 150 code points a
23
+ * session gets; a 200 code point query made of astral characters may be cut earlier, which is safe and well formed.
24
+ * 5. Cut by code points (an emoji is one, so a surrogate pair is never split). If the cut falls inside an unbroken
25
+ * ASCII token, that token is dropped: the half of a key that survives a cut is the half nobody masked.
26
+ *
27
+ * This is not anonymization. Names, street addresses and ordinary prose go through, and the consent line says so.
28
+ */
29
+ import { searchOptionText, WORK_SEARCH_LIMITS } from './work-search.js'
30
+
31
+ export const FIND_TITLE_CHARS = 90
32
+ export const FIND_PROMPT_CHARS = 150
33
+ export const FIND_QUERY_CHARS = 200
34
+ /** The collector keeps 2,000 characters of a typed prompt (FIND_PROMPT_KEEP_CHARS); a prompt that long was cut there. A test holds the two together. */
35
+ export const UPSTREAM_PROMPT_CUT = 2_000
36
+ /** Code points read before redacting: seven times the longest cut, so a pair or a block that starts inside a cut is whole. */
37
+ export const FIND_SCAN_CHARS = 1_000
38
+ /** Input bound before anything is normalized, so a pathological caller cannot make NFKC or the scan expensive. */
39
+ const INPUT_UNITS = 8_000
40
+ export const HIDDEN = '[hidden]'
41
+
42
+ const keyShapes: RegExp[] = [
43
+ /(?<![A-Za-z0-9])(?:sk|sess)-[A-Za-z0-9_\-]{16,}/g,
44
+ /(?<![A-Za-z0-9])(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{8,}/g,
45
+ /(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{16,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{16,}/g,
46
+ /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_\-]{16,}/g,
47
+ /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9\-]{10,}/g,
48
+ /hooks\.slack\.com\/services\/[A-Za-z0-9/_\-]+/g,
49
+ /(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{12,}/g,
50
+ /(?<![A-Za-z0-9])eyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}/g,
51
+ /(?<![A-Za-z0-9])bearer\s+[A-Za-z0-9._~+/\-]{16,}=*/gi,
52
+ /authorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/]{8,}={0,2}/gi,
53
+ /(?<![A-Za-z0-9])basic\s+[A-Za-z0-9+/]{16,}={1,2}/gi,
54
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[^-]*(?:-----END [A-Z ]*PRIVATE KEY-----)?/g,
55
+ /[A-Za-z][A-Za-z0-9+.\-]*:\/\/[^/\s:@'"]+:[^/\s@'"]+@/g,
56
+ /(?<![A-Za-z0-9])--(?:password|passwd|token|secret|api[_-]?key)(?:=|\s+)\S{4,}/gi,
57
+ ]
58
+ const pairKeyShape = /(?<![A-Za-z0-9])(["']?)([A-Za-z_][A-Za-z0-9_.\-]*)\1[ \t]*[:=][ \t]*/g
59
+ const pairValueShape = /^(?:"[^"\\]{4,}"|'[^'\\]{4,}'|[^\s"',;]{6,})/
60
+ const longRunShape = /(?<![A-Za-z0-9_\-])[A-Za-z0-9_\-]{32,}(?![A-Za-z0-9_\-])/g
61
+
62
+ /** A key that names a credential: it holds PASSWORD or PASSWD, or ends in a credential word. A copy of SetupAssistant.credentialKey. */
63
+ export function credentialKey(key: string): boolean {
64
+ let snake = ''
65
+ let previous = ''
66
+ for (const ch of key) {
67
+ if (/[A-Z]/.test(ch) && previous && (/[a-z]/.test(previous) || /[0-9]/.test(previous))) snake += '_'
68
+ snake += ch
69
+ previous = ch
70
+ }
71
+ const upper = snake.toUpperCase().replace(/-/g, '_').replace(/\./g, '_')
72
+ if (upper.includes('PASSWORD') || upper.includes('PASSWD')) return true
73
+ const words = ['PASS', 'SECRET', 'TOKEN', 'APIKEY', 'API_KEY', 'PRIVATE_KEY', 'ACCESS_KEY', 'ACCESS_KEY_ID', 'SECRET_KEY', 'AUTH_KEY', 'CREDENTIALS']
74
+ return words.some(word => upper === word || upper.endsWith('_' + word))
75
+ }
76
+
77
+ /** A name, not a key: a UUID, a name that carries one, or a plan or file name of words joined by underscores or hyphens. */
78
+ function readsAsName(run: string): boolean {
79
+ const rest = run.replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, '')
80
+ const segments = rest.split(/[_-]/).filter(Boolean)
81
+ if (rest.length < run.length && segments.every(s => /^\p{L}+$/u.test(s))) return true
82
+ const humps = (word: string) => { let count = 0, previous = ''; for (const ch of word) { if (/[A-Z]/.test(ch) && previous && /[a-z]/.test(previous)) count++; previous = ch } return count }
83
+ return segments.filter(s => s.length >= 4 && /^\p{L}+$/u.test(s) && humps(s) <= 1).length >= 2
84
+ }
85
+
86
+ /** Port of SetupAssistant.mask (Control, Sources/SetupAssistant.swift). Tests pin it to the same fake secrets. */
87
+ export function maskKeyShapes(text: string): string {
88
+ let out = text
89
+ for (const re of keyShapes) out = out.replace(new RegExp(re.source, re.flags), HIDDEN)
90
+ // Credential pairs: found on the string as it stands, then the ranges are hidden together (as the Swift does).
91
+ const hide: Array<[number, number]> = []
92
+ let resume = 0
93
+ for (const match of out.matchAll(new RegExp(pairKeyShape.source, 'g'))) {
94
+ const start = match.index!, end = start + match[0].length
95
+ if (start < resume) continue
96
+ const key = match[2]!
97
+ if (!credentialKey(key)) continue
98
+ const value = pairValueShape.exec(out.slice(end))
99
+ if (!value) continue
100
+ // A bare "pass" is a word as often as a password ("First pass: reviewed"): it hides only a value with a digit in it.
101
+ if (key.toLowerCase() === 'pass' && !/[0-9]/.test(value[0])) continue
102
+ hide.push([start, end + value[0].length])
103
+ resume = end + value[0].length
104
+ }
105
+ let result = '', cursor = 0
106
+ for (const [from, to] of hide) { result += out.slice(cursor, from) + HIDDEN; cursor = to }
107
+ out = result + out.slice(cursor)
108
+ return out.replace(new RegExp(longRunShape.source, 'g'), run => (/\p{L}/u.test(run) && /[0-9]/.test(run) && !readsAsName(run)) ? HIDDEN : run)
109
+ }
110
+
111
+ // What mask does not know. Each is broad on purpose: a number or a code wrongly hidden costs a little search signal, and one
112
+ // left in goes to a third party.
113
+ const PHONE_NANP = /(?<![\d])(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}(?!\d)/g
114
+ const PHONE_INTERNATIONAL = /(?<![\d\w])\+\d{1,3}[\s.\-]?\(?\d{1,4}\)?(?:[\s.\-]?\d{2,4}){2,4}(?!\d)/g
115
+ const CARD_NUMBER = /(?<!\d)(?:\d[ \-]?){12,18}\d(?!\d)/g
116
+ const SSN = /(?<!\d)\d{3}[\- ]\d{2}[\- ]\d{4}(?!\d)/g
117
+ const SSN_LABELLED = /\b(?:ssn|social security(?: number)?)\D{0,16}\d{9}(?!\d)/gi
118
+ const SHORT_STRIPE_KEY = /(?<![A-Za-z0-9])(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9_]+/g
119
+ const BASE64_RUN = /(?<![A-Za-z0-9+/_\-])[A-Za-z0-9+/]{24,}={0,2}(?![A-Za-z0-9+/])/g
120
+ // A credential word in other languages, with its value: "contraseña: Clave9876", "Passwort = Geheim1234".
121
+ const FOREIGN_CREDENTIAL = /(?<![\p{L}\p{N}])(?:contrase[ñn]a|mot de passe|passwort|kennwort|senha|palavra-passe|wachtwoord|lösenord|hasło|пароль|密码|パスワード|비밀번호)[ \t]*[:=:][ \t]*\S{4,}/giu
122
+
123
+ // A pair whose key ends in KEY, which mask's credentialKey list (API_KEY, ACCESS_KEY, SECRET_KEY...) does not: STRIPE_KEY=, OPENAI_KEY=,
124
+ // AccountKey=, license-key:, and the spoken forms "api key:" and "secret key=". A bare "key: value" is a YAML word as often as a
125
+ // secret, so it hides only a value of 8 or more characters that has a digit in it. "monkey=" and "hotkey" end in the letters but not
126
+ // at a word boundary, so they are left alone.
127
+ // PARITY: these rules, the unicode address rule and the invisible-character strip are THIS module's. Control's SetupAssistant.mask does
128
+ // not have them, which is safe (Control masks only what it draws; this decides what leaves the Mac). Do not add them to the Swift
129
+ // pin (`SWIFT_MASK` below tests maskKeyShapes alone).
130
+ const KEY_VALUE = String.raw`(?:"[^"\\]{4,}"|'[^'\\]{4,}'|[^\s"',;]{6,})`
131
+ const KEY_PAIR = new RegExp(String.raw`(?<![A-Za-z0-9])(?:[A-Za-z0-9_.\-]*[_.\-](?:key|KEY|Key)|[A-Za-z0-9_.\-]*(?<=[a-z0-9])Key)["']?[ \t]*[:=][ \t]*${KEY_VALUE}`, 'g')
132
+ const SPOKEN_KEY_PAIR = new RegExp(String.raw`(?<![A-Za-z0-9])(?:api|secret|access|private|auth|account|license|licence|encryption|signing)[ ]key["']?[ \t]*[:=][ \t]*${KEY_VALUE}`, 'gi')
133
+ const BARE_KEY_PAIR = /(?<![A-Za-z0-9_.\-])(?:key|KEY|Key)[ \t]*[:=][ \t]*(?=[^\s"',;]*\d)[^\s"',;]{8,}/g
134
+ // An address with letters outside ASCII (josé@example.com); the server's own rule is ASCII only.
135
+ const UNICODE_EMAIL = /[\p{L}\p{N}._%+\-]{1,64}@[\p{L}\p{N}.\-]{1,253}\.\p{L}{2,24}/gu
136
+
137
+ // A path into someone's home folder carries the account name and the layout of the machine, and the consent line does not promise
138
+ // either, so it goes out before anything else is read. Only a REAL path shape counts, so a word is not eaten: a root, an account name
139
+ // and at least one more segment (`/home/dashboard` and `/Users/list` are words), or `~/` with a segment that holds a slash or an
140
+ // extension (`~/month` is a word), or `~name/…`, or file://. Case does not matter (/users/, c:\users\), nor the form it arrives in:
141
+ // /Volumes/<x>/Users, /System/Volumes/Data/Users, /mnt/c/Users, C:\\Users\\ (escaped backslashes), `My\ Clients/Acme\ Corp` (escaped
142
+ // spaces) and an apostrophe in the name (/Users/o'brien/x). A path that opens inside a quote runs to the closing quote, spaces and dots
143
+ // and all; any other runs to the next unescaped space.
144
+ const HOME_PREFIX = String.raw`(?:(?:\/System\/Volumes\/Data|\/Volumes\/[^\/\n]+|\/mnt\/[a-z])?\/(?:users|home)\/[^\s\/\\"\x60]+\/|[a-z]:\\{1,2}users\\{1,2}[^\s\\"\x60]+\\{1,2}|~[\w.\-]*\/(?=(?:\\ |[^\s])*[\/.]))`
145
+ const HOME_SHAPE_QUOTED = /^(?:file:\/\/|(?:\/System\/Volumes\/Data|\/Volumes\/[^\/\n]+|\/mnt\/[a-z])?\/(?:users|home)\/[^\/\\\n]+[\/\\][^\n]|[a-z]:\\{1,2}users\\{1,2}[^\\\n]+\\{1,2}[^\n]|~[\w.\-]*\/[^\n]*[\/.])/i
146
+ const QUOTED_HOME_PATH = /(["'\x60])((?:(?!\1)[^\n])+)\1/g
147
+ const BARE_HOME_PATH = new RegExp(String.raw`(?<![\w.\-\/\\~])(?:file:\/\/(?:\\ |\S)*|${HOME_PREFIX}(?:\\ |\S)+)`, 'gi')
148
+ export const PATH_PLACEHOLDER = '[path]'
149
+ const maskQuotedHomePaths = (text: string) => text.replace(QUOTED_HOME_PATH, (whole, _quote: string, inner: string) => HOME_SHAPE_QUOTED.test(inner.trim()) ? PATH_PLACEHOLDER : whole)
150
+
151
+ // A link to a machine: an IP address or `localhost` WITH a port or a path (`192.168.1.20:8080/admin?x=1`, `localhost:3141/api/keys`).
152
+ // Scheme URLs go later, in searchOptionText. Left alone on purpose: a bare domain (`quilt.com`, a brand people search for), a bare
153
+ // address or `localhost` with nothing after it, a version like `1.2.3.4`, and `word.ext:number` (`meeting.ts:2452` is code, not a link).
154
+ const OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`
155
+ const HOST_LINK = new RegExp(String.raw`(?<![\w.@\-])(?:(?:${OCTET}\.){3}${OCTET}|localhost)(?::\d{2,5}(?:\/[^\s]*)?|\/[^\s]*)`, 'gi')
156
+ const LINK_PLACEHOLDER = '[link]'
157
+ const maskHostLinks = (text: string) => text.replace(HOST_LINK, LINK_PLACEHOLDER)
158
+
159
+ /**
160
+ * What an agent app puts in front of the words: `@"/path/with spaces.png"`, `@'path'`, `@/path/file`, `@~/file`, `[Image: source: path]`,
161
+ * `[Image #1]`, `[Pasted text #1 +20 lines]`, and a bare path. Measured on the owner's newest 508 first prompts: 33 start with `@"`,
162
+ * 19 with `[Image`, 3 with a bare path; 25 carry more than one in a row. They are not what the person was asking, they eat the 150
163
+ * characters that make the match, and a path in them names the account. They are dropped from the front, repeatedly, until the words
164
+ * begin. Only known forms: `@"Jane Doe" can you` (no slash, no file extension), an `@name` mention and `[File upload bug]` are words
165
+ * and stay. `cut` says the text was already cut upstream (an 80 or 120 character title, a 2,000 character prompt): an attachment that
166
+ * opens and never closes then runs to the end of what is left, so nothing of it is kept.
167
+ */
168
+ const FILE_EXT = String.raw`(?:png|jpe?g|gif|heic|webp|pdf|txt|md|tsx?|jsx?|json|py|swift|csv|xlsx?|docx?|pptx?|html?|css|log|zip|mov|mp4|wav|mp3|sh|ya?ml|toml)`
169
+ const LEADING_ATTACHMENT = new RegExp(String.raw`^\s*(?:@"(?=[^"]*(?:\/|\.${FILE_EXT}"))[^"]*"|@'(?=[^']*(?:\/|\.${FILE_EXT}'))[^']*'|@(?:~|\.{0,2})?\/(?:\\ |\S)*|@[^\s\/]*\.${FILE_EXT}\b|\[(?:Image(?: #\d+|:[^\]]*)|Pasted text[^\]]*|Pasted image[^\]]*)\]|(?:\/users|\/home)\/[^\s\/]+\/(?:\\ |\S)+|~\/(?=\S*[\/.])(?:\\ |\S)+|file:\/\/\S+)\s*`, 'i')
170
+ const UNCLOSED_ATTACHMENT = /^\s*(?:@"|@'|\[(?:Image|Pasted))/i
171
+ export function stripLeadingAttachments(text: string, cut = false): string {
172
+ let out = text
173
+ for (let i = 0; i < 20; i++) {
174
+ const next = out.replace(LEADING_ATTACHMENT, '')
175
+ if (next === out) break
176
+ out = next
177
+ }
178
+ return cut && UNCLOSED_ATTACHMENT.test(out) ? '' : out
179
+ }
180
+
181
+ export function maskMore(text: string): string {
182
+ // An address goes out as a space, as searchOptionText does, so a query that is only an address is left empty.
183
+ let out = maskHostLinks(maskQuotedHomePaths(text).replace(BARE_HOME_PATH, PATH_PLACEHOLDER)).replace(UNICODE_EMAIL, ' ')
184
+ for (const re of [KEY_PAIR, SPOKEN_KEY_PAIR, BARE_KEY_PAIR, FOREIGN_CREDENTIAL, SHORT_STRIPE_KEY, SSN_LABELLED, SSN, CARD_NUMBER, PHONE_INTERNATIONAL, PHONE_NANP]) out = out.replace(re, HIDDEN)
185
+ // Base64 that carries + or /, and the padded or long kind: mixed case with a digit, so a path or a word is left alone.
186
+ return out.replace(BASE64_RUN, run => (/[A-Z]/.test(run) && /[a-z]/.test(run) && /\d/.test(run) && (/[+/=]/.test(run) || run.length >= 40)) ? HIDDEN : run)
187
+ }
188
+
189
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
190
+ /** Lone surrogates, control and bidi characters out, NFKC, one line. */
191
+ function readableLine(raw: string): string {
192
+ const wellFormed = raw.slice(0, INPUT_UNITS).replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '')
193
+ return wellFormed.normalize('NFKC')
194
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g, '')
195
+ .replace(/\s+/g, ' ').trim()
196
+ }
197
+
198
+ const TOKEN_CHAR = /[A-Za-z0-9_\-+/=.:@%]/
199
+
200
+ /**
201
+ * At most `max` code points. When the cut falls inside an unbroken ASCII token (the last kept character and the next one
202
+ * both belong to it), the kept part of that token is dropped too: half a key is the half nobody masked. Text in a script that
203
+ * does not use spaces has no such token and is cut where it falls. `cutEarlier` says an earlier step already cut the text.
204
+ */
205
+ export function cutAtCodePoints(text: string, max: number, cutEarlier = false): string {
206
+ const points = Array.from(text)
207
+ if (points.length <= max && !cutEarlier) return text
208
+ const kept = points.slice(0, max)
209
+ const next = points[max]
210
+ const inside = cutEarlier ? true : next !== undefined && TOKEN_CHAR.test(next) && kept.length > 0 && TOKEN_CHAR.test(kept[kept.length - 1]!)
211
+ if (inside) while (kept.length > 0 && TOKEN_CHAR.test(kept[kept.length - 1]!)) kept.pop()
212
+ return kept.join('').trim()
213
+ }
214
+
215
+ /**
216
+ * Read, redact, then cut. Returns one clean line of at most `max` code points, possibly empty. `cutUpstream` says the text was already
217
+ * cut by whoever made it (the list cuts a first line at 80 and a title at 120, before any redaction), so its last token may be half a key.
218
+ */
219
+ export function redactForModel(raw: string, max: number, cutUpstream = false): string {
220
+ if (typeof raw !== 'string' || !raw) return ''
221
+ const scan = cutAtCodePoints(readableLine(raw), FIND_SCAN_CHARS)
222
+ // work-search's own slice at 300 UTF-16 units can leave half of a surrogate pair; it goes before the cut by code points.
223
+ const redacted = searchOptionText(maskKeyShapes(maskMore(scan))).replace(LONE_SURROGATE, '')
224
+ // searchOptionText slices at its own 300 UTF-16 units; if it reached them it may have cut inside a token.
225
+ return cutAtCodePoints(redacted, max, cutUpstream || redacted.length >= WORK_SEARCH_LIMITS.optionChars)
226
+ }
227
+
228
+ /** The search words as they leave: redacted like everything else, at most 200 code points. */
229
+ export function redactQuery(query: string): string { return redactForModel(query, FIND_QUERY_CHARS) }
230
+
231
+ export interface CandidateTexts {
232
+ /** What the model reads for this session: the title and the first prompt, or the prompt alone when it begins with the title. */
233
+ option: string
234
+ /** The first-prompt part, redacted and cut. This is `judged_text` on the wire. */
235
+ prompt: string
236
+ }
237
+
238
+ const norm = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
239
+
240
+ /**
241
+ * One session as the model sees it (the canary's shape B: 19 of 20 in the top three against 15 for the title alone):
242
+ * `title: first prompt`. The list titles a session by the first line of its first prompt, and then the title is the start
243
+ * of the prompt, so the prompt alone says it and a second copy of the words would only cost tokens.
244
+ */
245
+ export function candidateTexts(title: string, typed: string): CandidateTexts {
246
+ const titleCut = title.length === 80 || title.length === 120
247
+ const t = redactForModel(stripLeadingAttachments(title, titleCut), FIND_TITLE_CHARS, titleCut)
248
+ const promptCut = typed.length >= UPSTREAM_PROMPT_CUT
249
+ const prompt = redactForModel(stripLeadingAttachments(typed, promptCut), FIND_PROMPT_CHARS)
250
+ if (!prompt) return { option: t, prompt: '' }
251
+ const titleWords = norm(t)
252
+ if (!titleWords || (titleWords.length >= 12 && norm(prompt).startsWith(titleWords.slice(0, 30)))) return { option: prompt, prompt }
253
+ return { option: `${t}: ${prompt}`, prompt }
254
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Find a session by meaning (Control 0.5.363): `POST /api/agent-sessions/find`.
3
+ *
4
+ * Control's command panel sends what the person typed. This reads the Mac's own session store (never client text), shows a
5
+ * decision model each session's title and the first 150 characters of its first prompt, and answers the few sessions that
6
+ * model thinks were meant, with how sure it is. What it does and why it is shaped this way:
7
+ * - session-find-collect.ts: the pool (newest 254, or 508 when the first 254 do not settle it), its filters and its memo.
8
+ * - session-find-redact.ts: read, redact, then cut every string that leaves the Mac, and every string handed back.
9
+ * - session-find-ask.ts: the Choice, the merge, the yes/no view, the floors and bands.
10
+ *
11
+ * WHAT LEAVES THE MAC. Per search: the redacted words typed, plus a redacted title and first-prompt excerpt of up to 508
12
+ * recent sessions, to the person's selected decision provider (Settings > Decision models, the `search` setting, no fallback
13
+ * to the other provider). Nothing leaves before the person has consented for THAT provider: the body names the provider they
14
+ * consented to, and a mismatch with the provider selected now is refused before any session is read.
15
+ *
16
+ * COST CONTROLS, separate from Work search, Work intake and session advice (so one runaway cannot spend another's budget):
17
+ * its own Jev ledger and SubQ ledger (session-find-jev-usage.json, session-find-subq-usage.json), a daily input-token cap
18
+ * of 3,000,000 for each (COS_SESSION_FIND_DAILY_TOKENS), each provider's own breaker (3 failures, then an hour), and a switch
19
+ * (COS_SESSION_FIND=0 turns it off; the value is read at boot like COS_JEV_SEARCH). The ledger day is UTC.
20
+ *
21
+ * It is also gated on the Sessions feature (COS_CLAUDE_SESSIONS_ENABLED=1), which is off by default on the published package:
22
+ * a person who never turned sessions on never sees a session leave their Mac.
23
+ */
24
+ import { dataPath } from './data-dir.js'
25
+ import { JevClient, JevError } from './jev.js'
26
+ import {
27
+ DecisionProviderError, SubqDecisionClient, createDecisionClient, selectedDecisionProvider, type DecisionClient, type DecisionProvider,
28
+ } from './decision-provider.js'
29
+ import { resolveJevKey } from './jev.js'
30
+ import { resolveSubqKey } from './subq-key.js'
31
+ import { isAbortError, sharedSessionFindCollector, type FindCandidate, type SessionFindCollector } from './session-find-collect.js'
32
+ import { FIND_CHOICE_OPTIONS, SessionFindAsker, type AskCandidate, type FindStrength } from './session-find-ask.js'
33
+ import { candidateTexts, redactQuery } from './session-find-redact.js'
34
+ import { agentSessionRoots, type AgentSessionRoots } from './agent-session-store.js'
35
+ import { listKeyedSessionsStrict } from './keyed-sessions.js'
36
+
37
+ export const SESSION_FIND_LIMITS = {
38
+ /** After redaction, in code points. Control sends three or more words; a shorter redacted query is not worth a model call. */
39
+ queryMin: 3,
40
+ /** Bytes of JSON-string a query may arrive as, before anything reads it. The 200 code point cap is applied to the redacted words. */
41
+ queryRawMax: 2_000,
42
+ defaultDailyTokens: 3_000_000,
43
+ } as const
44
+ export const SESSION_FIND_JEV_USAGE_FILE = dataPath('session-find-jev-usage.json')
45
+ export const SESSION_FIND_SUBQ_USAGE_FILE = dataPath('session-find-subq-usage.json')
46
+
47
+ export type SessionFindReason =
48
+ | 'consent_required' | 'provider_changed' | 'decision_not_configured' | 'jev_not_configured' | 'sessions_off' | 'find_off'
49
+ | 'cap_reached' | 'breaker_open' | 'query_too_short' | 'failed'
50
+ export const SESSION_FIND_MESSAGES: Record<SessionFindReason, string> = {
51
+ consent_required: 'Meaning search needs your okay before it sends anything. Turn it on in Settings.',
52
+ provider_changed: 'The decision model changed since you agreed. Agree again for the new one in Settings.',
53
+ decision_not_configured: 'Meaning search needs an API key for the selected decision model. Add one in Settings.',
54
+ jev_not_configured: 'Meaning search needs a TypeSafe key. Add one in Settings.',
55
+ sessions_off: 'Sessions are turned off on this server, so there is nothing to search.',
56
+ find_off: 'Meaning search is turned off on this server.',
57
+ cap_reached: 'Meaning search has used today’s budget. It resets at midnight UTC.',
58
+ breaker_open: 'Meaning search is paused for an hour after repeated failures.',
59
+ query_too_short: 'Type a few more words to search by meaning.',
60
+ failed: 'Meaning search could not answer this time.',
61
+ }
62
+
63
+ /** The daily input-token cap for session find alone, per provider ledger. The ledger day is UTC. */
64
+ export function sessionFindDailyCap(env: NodeJS.ProcessEnv = process.env): number {
65
+ const raw = Number(env.COS_SESSION_FIND_DAILY_TOKENS)
66
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : SESSION_FIND_LIMITS.defaultDailyTokens
67
+ }
68
+
69
+ /** On unless COS_SESSION_FIND says off (0, false, no, off). Fixed when the server starts, like COS_JEV_SEARCH. */
70
+ export function sessionFindEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
71
+ return !['0', 'false', 'no', 'off'].includes((env.COS_SESSION_FIND ?? '').trim().toLowerCase())
72
+ }
73
+
74
+ /**
75
+ * Whether this server can find sessions by meaning at all: the Sessions feature is on and the switch is not off. This is what
76
+ * `capabilities.sessionFind` on GET /api/decision-provider reports, so Control never offers a consent it cannot honour.
77
+ * The Sessions test is claudeSessionsEnabled() in routes/claude-sessions.ts (a test holds the two together; that module is
78
+ * not imported here because it pulls in the whole registry).
79
+ */
80
+ export function sessionFindAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
81
+ return env.COS_CLAUDE_SESSIONS_ENABLED === '1' && sessionFindEnabled(env)
82
+ }
83
+
84
+ /**
85
+ * API POSTs that only read, for the mutation lease (index.ts). Finding a session reads the session store and asks a model;
86
+ * it writes only its own token ledger. Under the lease a drain for an update would wait for a search that can take a few
87
+ * seconds, and the helper's own deadline is shorter than the lease's. Its own predicate, not isReadOnlyApiPost's list: that
88
+ * list differs between the public and the private line, and a pinned test holds its single call site.
89
+ */
90
+ export function isSessionFindPost(method: string, path: string): boolean {
91
+ return method === 'POST' && path === '/agent-sessions/find'
92
+ }
93
+
94
+ /** Session find's own Jev client: its own ledger, cap and breaker. jev.ts supplies the key and the timeout. */
95
+ export function createSessionFindJevClient(fetchImpl: typeof fetch = fetch, now: () => Date = () => new Date(),
96
+ usageFile = SESSION_FIND_JEV_USAGE_FILE, cap: () => number = () => sessionFindDailyCap()): JevClient {
97
+ return new JevClient(fetchImpl, now, usageFile, cap)
98
+ }
99
+ let sharedJev: JevClient | null = null
100
+ /** One per process: it owns this feature's Jev breaker. Saving a new Jev key resets it (routes/jev.ts). */
101
+ export function sharedSessionFindJevClient(): JevClient { return sharedJev ??= createSessionFindJevClient() }
102
+
103
+ /** The selected provider's client for this feature: Jev on its own client, SubQ on its own ledger and cap. */
104
+ export function createSessionFindDecisionClient(jev: Pick<JevClient, 'ask'> = sharedSessionFindJevClient(), env: NodeJS.ProcessEnv = process.env,
105
+ subqUsageFile = SESSION_FIND_SUBQ_USAGE_FILE, cap: () => number = () => sessionFindDailyCap(env)): DecisionClient {
106
+ return createDecisionClient('search', jev, env, { usageFile: subqUsageFile, cap })
107
+ }
108
+ let sharedDecision: DecisionClient | null = null
109
+ export function sharedSessionFindDecisionClient(): DecisionClient { return sharedDecision ??= createSessionFindDecisionClient() }
110
+
111
+ export interface SessionFindRequest {
112
+ query: string
113
+ /** The provider the person agreed to send to, or null for "not agreed". */
114
+ consentedProvider: DecisionProvider | null
115
+ stage: 'first' | 'full'
116
+ }
117
+
118
+ export interface SessionFindRow {
119
+ session_id: string
120
+ provider: 'claude' | 'codex' | 'cursor'
121
+ slug: string
122
+ custom_title: string
123
+ display_label: string
124
+ first_prompt: string
125
+ discussion_summary: string
126
+ project: string
127
+ created: string
128
+ modified: string
129
+ duration_minutes: number
130
+ message_count: number
131
+ domain: string
132
+ device_id: string
133
+ machine_spawned: boolean
134
+ alive: boolean
135
+ state: 'recent'
136
+ pinned: boolean
137
+ last_activity_at: string
138
+ judged_text: string
139
+ probability: number
140
+ strength: FindStrength
141
+ compressed: boolean
142
+ }
143
+
144
+ export interface SessionFindAnswer {
145
+ available: boolean
146
+ reason: SessionFindReason | null
147
+ message: string
148
+ provider: DecisionProvider | null
149
+ /** True when the provider is documented to keep what it is sent (SubQ). False says nothing about TypeSafe, whose retention is not documented. */
150
+ storesData: boolean
151
+ rows: SessionFindRow[]
152
+ /** Sessions a model was asked about. */
153
+ asked: number
154
+ /** The oldest time among them (their last record), or null. */
155
+ reachedBack: string | null
156
+ /** Compressed Codex files this server could not read, so they were not asked about. */
157
+ skipped: number
158
+ /** No model was called for this answer. */
159
+ cached: boolean
160
+ /** On `cap_reached`: when that provider's ledger next resets. On `breaker_open`: when the breaker reopens. ISO 8601 UTC; absent when not known. */
161
+ resetsAt?: string
162
+ }
163
+
164
+ export function unavailableAnswer(reason: SessionFindReason, provider: DecisionProvider | null = null, resetsAt?: string): SessionFindAnswer {
165
+ return { available: false, reason, message: SESSION_FIND_MESSAGES[reason], provider, storesData: provider === 'subq', rows: [], asked: 0, reachedBack: null, skipped: 0, cached: false, ...(resetsAt ? { resetsAt } : {}) }
166
+ }
167
+
168
+ /**
169
+ * When a cap or a pause ends, read from the clients' own bookkeeping and never guessed. Both ledgers (JevClient `today()`, SubqDecisionClient
170
+ * `day()`) key a day by `now.toISOString().slice(0, 10)`, so a cap clears at the next UTC midnight of the client's clock. A breaker opens until
171
+ * the client's `breakerOpenUntil` (Jev reports it as an ISO string, SubQ as epoch milliseconds). Unknown stays undefined.
172
+ */
173
+ export function resetsAtFor(reason: SessionFindReason, client: DecisionClient, provider: DecisionProvider, jev: JevClient | null, nowMs: number): string | undefined {
174
+ if (reason === 'cap_reached') {
175
+ const day = new Date(nowMs)
176
+ return new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate() + 1)).toISOString()
177
+ }
178
+ if (reason !== 'breaker_open') return undefined
179
+ let until: number | null = null
180
+ if (provider === 'subq') { if (client instanceof SubqDecisionClient) until = client.status().breakerOpenUntil }
181
+ else if (jev) { const iso = jev.status().breakerOpenUntil; until = iso ? Date.parse(iso) : null }
182
+ return until !== null && Number.isFinite(until) && until > nowMs ? new Date(until).toISOString() : undefined
183
+ }
184
+
185
+ /** Every failure a model or its ledger can raise, as one of the contract's reasons. Anything not named is `failed`. */
186
+ export function reasonFor(error: unknown): SessionFindReason {
187
+ if (error instanceof DecisionProviderError) {
188
+ if (error.code === 'decision_cap_reached') return 'cap_reached'
189
+ if (error.code === 'decision_breaker_open') return 'breaker_open'
190
+ if (error.code === 'decision_not_configured') return 'decision_not_configured'
191
+ return 'failed'
192
+ }
193
+ if (error instanceof JevError) {
194
+ if (error.code === 'jev_cap_reached') return 'cap_reached'
195
+ if (error.code === 'jev_breaker_open') return 'breaker_open'
196
+ if (error.code === 'jev_not_configured') return 'jev_not_configured'
197
+ return 'failed'
198
+ }
199
+ return 'failed'
200
+ }
201
+
202
+ export interface SessionFindDependencies {
203
+ collector: Pick<SessionFindCollector, 'collect'>
204
+ asker: Pick<SessionFindAsker, 'ask'>
205
+ decision: DecisionClient
206
+ roots: () => AgentSessionRoots
207
+ keyedIds: () => ReadonlySet<string>
208
+ available: () => { find: boolean; sessions: boolean }
209
+ /** The provider the person has selected for the `search` setting. Throws DecisionProviderError when the setting is invalid. */
210
+ selected: () => DecisionProvider
211
+ configured: (provider: DecisionProvider) => boolean
212
+ /** Cap or breaker already reached for this provider, known without sending anything. */
213
+ capacity: (client: DecisionClient, provider: DecisionProvider, jev: JevClient | null) => 'cap_reached' | 'breaker_open' | null
214
+ jev: JevClient | null
215
+ chunkSize?: number
216
+ now?: () => number
217
+ }
218
+
219
+ /**
220
+ * Cap and breaker, read from the two clients' own status. SubQ's status reads an unreadable ledger as the cap reached. Jev's reads
221
+ * a missing or unreadable ledger as 0 used (jev.ts usedToday), so this check cannot catch one; the client's own write would.
222
+ */
223
+ export function clientCapacity(client: DecisionClient, provider: DecisionProvider, jev: JevClient | null): 'cap_reached' | 'breaker_open' | null {
224
+ if (provider === 'subq') {
225
+ if (!(client instanceof SubqDecisionClient)) return null
226
+ const status = client.status()
227
+ if (status.breakerOpenUntil) return 'breaker_open'
228
+ return status.usedToday >= status.dailyCap ? 'cap_reached' : null
229
+ }
230
+ if (!jev) return null
231
+ const status = jev.status()
232
+ if (status.breakerOpenUntil) return 'breaker_open'
233
+ return status.usedToday >= status.dailyCap ? 'cap_reached' : null
234
+ }
235
+
236
+ export function defaultSessionFindDependencies(): SessionFindDependencies {
237
+ return {
238
+ collector: sharedSessionFindCollector(),
239
+ asker: new SessionFindAsker(),
240
+ decision: sharedSessionFindDecisionClient(),
241
+ roots: () => agentSessionRoots(),
242
+ // Strict: a damaged keyed-sessions file must fail the search (reported as `failed`), not read as no keyed sessions.
243
+ keyedIds: () => new Set(listKeyedSessionsStrict().map(record => record.id.toLowerCase())),
244
+ available: () => ({ find: sessionFindEnabled(), sessions: process.env.COS_CLAUDE_SESSIONS_ENABLED === '1' }),
245
+ selected: () => selectedDecisionProvider('search'),
246
+ configured: provider => provider === 'subq' ? !!resolveSubqKey() : !!resolveJevKey(),
247
+ capacity: clientCapacity,
248
+ jev: sharedSessionFindJevClient(),
249
+ }
250
+ }
251
+
252
+ const iso = (ms: number) => new Date(ms).toISOString()
253
+
254
+ export class SessionFinder {
255
+ constructor(private readonly deps: SessionFindDependencies = defaultSessionFindDependencies()) {}
256
+
257
+ /**
258
+ * Never throws for a model or ledger failure: those are answers with a reason. A thrown AbortError means the caller went
259
+ * away; the route does not answer it.
260
+ */
261
+ async find(request: SessionFindRequest, signal: AbortSignal): Promise<SessionFindAnswer> {
262
+ const { deps } = this
263
+ const live = deps.available()
264
+ if (!live.find) return unavailableAnswer('find_off')
265
+ if (!live.sessions) return unavailableAnswer('sessions_off')
266
+
267
+ // Everything below that can refuse does so before a single session is read or a single byte is sent.
268
+ let provider: DecisionProvider
269
+ try { provider = deps.selected() } catch { return unavailableAnswer('failed') }
270
+ if (!request.consentedProvider) return unavailableAnswer('consent_required', provider)
271
+ if (request.consentedProvider !== provider) return unavailableAnswer('provider_changed', provider)
272
+ const query = redactQuery(request.query)
273
+ if (Array.from(query).length < SESSION_FIND_LIMITS.queryMin) return unavailableAnswer('query_too_short', provider)
274
+ if (!deps.configured(provider)) return unavailableAnswer(provider === 'jev' ? 'jev_not_configured' : 'decision_not_configured', provider)
275
+ let client: DecisionClient
276
+ try { client = deps.decision.snapshot?.() ?? deps.decision } catch (error) { return unavailableAnswer(reasonFor(error), provider) }
277
+ // The snapshot is the provider this whole search uses. A settings change between the two reads above and this one is refused.
278
+ if ((client.provider ?? 'jev') !== provider) return unavailableAnswer('provider_changed', provider)
279
+ const full = deps.capacity(client, provider, deps.jev)
280
+ if (full) return unavailableAnswer(full, provider, resetsAtFor(full, client, provider, deps.jev, (deps.now ?? Date.now)()))
281
+
282
+ try {
283
+ const roots = deps.roots()
284
+ // Inside the try: an unreadable keyed-sessions file is a failed search, never an empty set (keyed text must not leave).
285
+ const keyedIds = deps.keyedIds()
286
+ const chunk = Math.min(deps.chunkSize ?? FIND_CHOICE_OPTIONS, FIND_CHOICE_OPTIONS)
287
+ const firstPool = await deps.collector.collect({ roots, limit: chunk, keyedIds, signal })
288
+ const byKey = new Map<string, FindCandidate & { judged: string }>()
289
+ const toAsk = (pool: readonly FindCandidate[]): AskCandidate[] => {
290
+ const out: AskCandidate[] = []
291
+ for (const candidate of pool) {
292
+ if (byKey.has(candidate.key)) continue
293
+ const { option, prompt } = candidateTexts(candidate.title, candidate.typed)
294
+ // A session whose words all redact away has nothing a model could judge it by.
295
+ if (!option) continue
296
+ byKey.set(candidate.key, { ...candidate, judged: prompt })
297
+ out.push({ key: candidate.key, option, clockMs: candidate.clockMs })
298
+ }
299
+ return out
300
+ }
301
+ const first = toAsk(firstPool.candidates)
302
+ let skipped = firstPool.skippedNoDecoder + firstPool.skippedUnreadable
303
+ const outcome = await deps.asker.ask({
304
+ client, query, stage: request.stage, first, chunkSize: chunk,
305
+ shouldStop: () => signal.aborted,
306
+ second: async () => {
307
+ const wider = await deps.collector.collect({ roots, limit: chunk * 2, keyedIds, signal })
308
+ skipped = Math.max(skipped, wider.skippedNoDecoder + wider.skippedUnreadable)
309
+ return toAsk(wider.candidates)
310
+ },
311
+ })
312
+ if (outcome.stopped) throw abortLike()
313
+ const rows: SessionFindRow[] = []
314
+ for (const row of outcome.rows) {
315
+ const candidate = byKey.get(row.key)
316
+ if (!candidate) continue
317
+ rows.push(toRow(candidate, row.probability, row.strength))
318
+ }
319
+ return {
320
+ available: true, reason: null, message: '', provider, storesData: provider === 'subq', rows,
321
+ asked: outcome.asked.length, reachedBack: outcome.asked.length ? iso(Math.min(...outcome.asked.map(c => c.clockMs))) : null,
322
+ skipped, cached: outcome.cached,
323
+ }
324
+ } catch (error) {
325
+ if (isAbortError(error)) throw error
326
+ const reason = reasonFor(error)
327
+ return unavailableAnswer(reason, provider, resetsAtFor(reason, client, provider, deps.jev, (deps.now ?? Date.now)()))
328
+ }
329
+ }
330
+ }
331
+
332
+ function abortLike(): Error { const e = new Error('cancelled'); e.name = 'AbortError'; return e }
333
+
334
+ /** The list's session fields (routes/agent-sessions.ts toEntry), without live state, plus what this search adds. */
335
+ function toRow(candidate: FindCandidate & { judged: string }, probability: number, strength: FindStrength): SessionFindRow {
336
+ const title = candidate.title
337
+ return {
338
+ session_id: candidate.session_id, provider: candidate.provider, slug: candidate.session_id,
339
+ custom_title: title, display_label: title, first_prompt: candidate.listFirstLine || title, discussion_summary: '',
340
+ project: candidate.project, created: candidate.created, modified: iso(candidate.mtimeMs),
341
+ duration_minutes: 0, message_count: 0, domain: '', device_id: candidate.provider, machine_spawned: false,
342
+ alive: false, state: 'recent', pinned: false,
343
+ last_activity_at: iso(candidate.clockMs), judged_text: candidate.judged, probability, strength, compressed: candidate.compressed,
344
+ }
345
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { existsSync, readFileSync } from 'node:fs'
14
14
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
15
+ import { sameSpeakerName } from './speaker-name-nfc.js'
15
16
 
16
17
  export interface CalibrationPurgeResult {
17
18
  /** Rows whose `speaker` matched and were dropped. */
@@ -51,7 +52,7 @@ export function filterCalibrationRows(
51
52
  kept.push(line)
52
53
  continue
53
54
  }
54
- if (speaker === speakerName) {
55
+ if (sameSpeakerName(speaker, speakerName)) {
55
56
  result.removed++
56
57
  continue
57
58
  }
@@ -90,7 +91,7 @@ export function relabelCalibrationRows(
90
91
  // merge must never lose history. (Contrast filterCalibrationRows, where
91
92
  // removed + retained = total.)
92
93
  retained++
93
- if (row.speaker === fromName) {
94
+ if (sameSpeakerName(row.speaker, fromName)) {
94
95
  row.speaker = toName
95
96
  relabeled++
96
97
  out.push(JSON.stringify(row))