@gotcos/glasses-server 6.94.1 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +27 -0
- package/package.json +2 -1
- package/server/index.ts +6 -0
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/held-naming-batches.ts +8 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/mac-speaker-machine-evidence.ts +111 -0
- package/server/lib/mac-speaker-profile-extractor.ts +41 -0
- package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
- package/server/lib/mac-speaker-protection.ts +22 -0
- package/server/lib/mac-speaker-reconcile.ts +300 -0
- package/server/lib/mac-speaker-repair-projection.ts +301 -0
- package/server/lib/mac-speaker-repair-render.ts +105 -0
- package/server/lib/mac-speaker-windows.ts +239 -0
- package/server/lib/mac-voice-assignment.ts +2 -1
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +46 -7
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/session-find-ask.ts +281 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +7 -2
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/meeting.ts +43 -15
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
- package/server/scripts/mac-speaker-repair.ts +324 -0
|
@@ -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
|
|
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
|
|
94
|
+
if (sameSpeakerName(row.speaker, fromName)) {
|
|
94
95
|
row.speaker = toName
|
|
95
96
|
relabeled++
|
|
96
97
|
out.push(JSON.stringify(row))
|