@gotcos/glasses-server 6.99.33 → 7.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// No absolute Mac path reaches the browser through a session's words (7.0.4 S1).
|
|
2
|
+
//
|
|
3
|
+
// WHY. A reply is sent as the agent wrote it, and agents link the files they touched with full
|
|
4
|
+
// paths: `[app.ts](/Users/<name>/repo/server/app.ts#L42)`, Codex's `[app.ts](/abs/app.ts:42)`, a
|
|
5
|
+
// bare `~/Documents/...`, a path in backticks. A read-only scan of 14 days of transcripts found 111
|
|
6
|
+
// Claude links and 826 Codex links carrying `/Users/...` into Control Web. The path names the
|
|
7
|
+
// person, their folders and their projects, and the browser has no use for it: it cannot open it.
|
|
8
|
+
//
|
|
9
|
+
// WHAT. Every private local path in a string becomes its base name, keeping a line anchor
|
|
10
|
+
// (`app.ts:42`, from `:42` or `#L42`). A Markdown link to a local file becomes its words (or the
|
|
11
|
+
// base name when it has none). Web URLs are untouched; relative paths stay as written; a site path
|
|
12
|
+
// such as `/api/x` is not a Mac path and stays too. `redactLocalPathsDeep` applies this to every
|
|
13
|
+
// string in a JSON value, so a field added to the session detail later is covered without a list.
|
|
14
|
+
//
|
|
15
|
+
// WHAT COUNTS AS LOCAL (narrowed in 7.0.4 B1). Only places that name this Mac's people and data:
|
|
16
|
+
// a path under `/Users/<name>`, `/Volumes/<name>`, `/private/...` (where /var and /tmp really
|
|
17
|
+
// live) or `/var/folders/...` (per-user temp), a home shorthand (`~/`, `~name/` with a letter
|
|
18
|
+
// first, `$HOME/`, `${HOME}/`), a `file://` URL, or an editor URL that opens a file
|
|
19
|
+
// (`vscode://file/...`). The `.cos-glasses` data folder lives under the home folder, so it is
|
|
20
|
+
// covered. Everything else is ordinary text and stays exactly as written: `/dev/null`,
|
|
21
|
+
// `/usr/bin/env`, `/tmp/out`, `/opt/homebrew`, `/home`, `/run/start`, `~80/100`, `~1/3`, `A/B`,
|
|
22
|
+
// dates and fractions. The first cut treated every Unix top-level folder as private and `~<digit>`
|
|
23
|
+
// as home, and rewrote prose: "Pacing ~80/100 leads" read "Pacing 100 leads", "#!/usr/bin/env
|
|
24
|
+
// node" read "#!env node".
|
|
25
|
+
//
|
|
26
|
+
// SPACES. A folder name with spaces (`/Users/x/Ukaoma Chief Of Staff/MU-Chief-Staff/CLAUDE.md`)
|
|
27
|
+
// is followed across capitalised words, but only when the continuation ends at a real file
|
|
28
|
+
// extension or a `/`. A continuation that ends anywhere else ("/Users/x/out Next Steps: Review
|
|
29
|
+
// A/B results") is not taken, so the words after a path are never eaten. Either way the private
|
|
30
|
+
// head (`/Users/<name>`) is removed.
|
|
31
|
+
|
|
32
|
+
/** Editor and file URL schemes whose target is a file on this Mac. */
|
|
33
|
+
const LOCAL_SCHEMES = ['file', 'vscode', 'vscode-insiders', 'cursor', 'windsurf', 'zed', 'idea', 'subl', 'txmt', 'mvim']
|
|
34
|
+
|
|
35
|
+
/** Delimiters a path never runs through (besides whitespace) in prose, Markdown and code. */
|
|
36
|
+
const DELIMS = `<>"'\`“”‘’|)\\]}*,;`
|
|
37
|
+
const STOP = `\\s${DELIMS}`
|
|
38
|
+
/** A root name in any letter case: APFS is case-insensitive, so `/USERS/jane` is the same folder as `/Users/jane`. */
|
|
39
|
+
function anyCase(word: string): string {
|
|
40
|
+
return [...word].map(c => /[a-z]/i.test(c) ? `[${c.toUpperCase()}${c.toLowerCase()}]` : c).join('')
|
|
41
|
+
}
|
|
42
|
+
const USERS = anyCase('Users')
|
|
43
|
+
/** A private root, followed by at least one more name: `/Users` alone, or "the /Users folder", is not a path. `//Users` counts. */
|
|
44
|
+
const PRIVATE_ROOT = `/+(?:${USERS}|${anyCase('Volumes')}|${anyCase('private')})(?=/[^${STOP}/])|/+${anyCase('var')}/${anyCase('folders')}(?=/)`
|
|
45
|
+
/** A home shorthand: `~/`, `~name/` (a letter first, so `~80/100` and `~1/3` stay numbers), `$HOME/`, `${HOME}/`. */
|
|
46
|
+
const HOME_SHORTHAND = `(?:~(?:[A-Za-z][A-Za-z0-9._-]*)?|\\$HOME|\\$\\{HOME\\})(?=/)`
|
|
47
|
+
const HEAD = `(?:${HOME_SHORTHAND}|${PRIVATE_ROOT})`
|
|
48
|
+
/** A bare path follows prose punctuation, never a path segment (including ./ or ../). Web spans are handled separately. */
|
|
49
|
+
const BARE = new RegExp(`(^|[^\\p{L}\\p{N}_./-])(${HEAD})([^${STOP}]*)`, 'gu')
|
|
50
|
+
const SCHEME_URL = new RegExp(`\\b(?:${LOCAL_SCHEMES.join('|')}):\\/\\/[^${STOP}]*`, 'gi')
|
|
51
|
+
/** Complete HTTP(S) tokens, including query/fragment paths. Keep their original bytes rather than reserializing a URL. */
|
|
52
|
+
const WEB_URL = /\bhttps?:\/\/[^\s<>"'`“”‘’]+/gi
|
|
53
|
+
/** `[words](destination)` and ``, the destination angle-wrapped or not (spaces allowed). */
|
|
54
|
+
const LINK = /(!?)\[([^\]\n]{0,500})\]\(\s*(<[^>\n]{1,4096}>|[^\s()<>\n][^)\n]{0,4096}?)\s*\)/g
|
|
55
|
+
const LOCAL_DEST = new RegExp(`^(?:${HEAD})`)
|
|
56
|
+
const LOCAL_SCHEME_DEST = new RegExp(`^(?:${LOCAL_SCHEMES.join('|')}):\\/\\/`, 'i')
|
|
57
|
+
/** One word of a folder name with spaces: capitalised or a number, no colon, and not ending a sentence. */
|
|
58
|
+
const SPACED_WORD = `[A-Z0-9](?:[^\\s/:${DELIMS}]*[^\\s/:.!?${DELIMS}])?`
|
|
59
|
+
/** One more space-separated folder name, while the words are capitalised and lead to another `/`. */
|
|
60
|
+
const SPACED_TAIL = new RegExp(`^( (?:${SPACED_WORD} ){0,4}${SPACED_WORD}/[^${STOP}]*)`)
|
|
61
|
+
/** A real file extension (with a letter in it, so `v1.2` and `10.15` are not), plus any `:line` anchors. */
|
|
62
|
+
const HAS_EXTENSION = /\.(?=[A-Za-z0-9]{0,9}[A-Za-z])[A-Za-z0-9]{1,10}(?::\d+)*$/
|
|
63
|
+
const TRAILING_PUNCTUATION = /[.:!?]+$/
|
|
64
|
+
|
|
65
|
+
function decoded(value: string): string {
|
|
66
|
+
try { return decodeURIComponent(value) } catch { return value }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The base name of a path, with any line anchor it carries (`:42`, or `#L42` turned into `:42`). */
|
|
70
|
+
function baseName(path: string): string {
|
|
71
|
+
let value = path
|
|
72
|
+
let anchor = ''
|
|
73
|
+
const hash = value.indexOf('#')
|
|
74
|
+
if (hash >= 0) {
|
|
75
|
+
const line = /^L(\d+)/i.exec(value.slice(hash + 1))
|
|
76
|
+
if (line) anchor = ':' + line[1]
|
|
77
|
+
value = value.slice(0, hash)
|
|
78
|
+
}
|
|
79
|
+
const query = value.indexOf('?')
|
|
80
|
+
if (query >= 0) value = value.slice(0, query)
|
|
81
|
+
const plain = decoded(value)
|
|
82
|
+
// The home folder itself names the person: it reads `~`, never its base name.
|
|
83
|
+
if (/^\/+users\/[^/]+\/?$/i.test(plain)) return '~' + anchor
|
|
84
|
+
const parts = plain.split('/').filter(Boolean)
|
|
85
|
+
const last = parts.pop() || '~'
|
|
86
|
+
return /:\d+$/.test(last) ? last : last + anchor
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A path that may be carried across spaces: it ends at a real file extension or at a folder separator. */
|
|
90
|
+
function endsAtFileOrFolder(path: string): boolean {
|
|
91
|
+
const core = path.replace(TRAILING_PUNCTUATION, '')
|
|
92
|
+
return core.endsWith('/') || HAS_EXTENSION.test(core)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function redactBare(text: string): string {
|
|
96
|
+
// Find the matches and their spaced tails first, then rebuild the string once.
|
|
97
|
+
const pieces: string[] = []
|
|
98
|
+
let cursor = 0
|
|
99
|
+
BARE.lastIndex = 0
|
|
100
|
+
for (let m = BARE.exec(text); m; m = BARE.exec(text)) {
|
|
101
|
+
const [match, before, head, body] = m
|
|
102
|
+
const start = m.index
|
|
103
|
+
if (start < cursor) continue
|
|
104
|
+
let path = head + body
|
|
105
|
+
let end = start + match.length
|
|
106
|
+
// A folder name with spaces is followed only when the continuation ends at a file extension or a `/`.
|
|
107
|
+
// A path that already ends that way, or that ends a sentence, is never extended.
|
|
108
|
+
if (!endsAtFileOrFolder(path) && !TRAILING_PUNCTUATION.test(path)) {
|
|
109
|
+
let probe = path
|
|
110
|
+
let probeEnd = end
|
|
111
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
112
|
+
const tail = SPACED_TAIL.exec(text.slice(probeEnd))
|
|
113
|
+
if (!tail) break
|
|
114
|
+
probe += tail[1]
|
|
115
|
+
probeEnd += tail[1].length
|
|
116
|
+
if (endsAtFileOrFolder(probe)) { path = probe; end = probeEnd; break }
|
|
117
|
+
if (TRAILING_PUNCTUATION.test(probe)) break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const punctuation = TRAILING_PUNCTUATION.exec(path)?.[0] ?? ''
|
|
121
|
+
const core = punctuation ? path.slice(0, -punctuation.length) : path
|
|
122
|
+
pieces.push(text.slice(cursor, start), before, baseName(core), punctuation)
|
|
123
|
+
cursor = end
|
|
124
|
+
BARE.lastIndex = Math.max(end, start + 1)
|
|
125
|
+
}
|
|
126
|
+
pieces.push(text.slice(cursor))
|
|
127
|
+
return pieces.join('')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** URL query values can look exactly like local paths; redact only the text between whole web tokens. */
|
|
131
|
+
function redactOutsideWebURLs(text: string): string {
|
|
132
|
+
const redact = (part: string) => redactBare(part.replace(SCHEME_URL,
|
|
133
|
+
url => baseName(url.replace(LOCAL_SCHEME_DEST, '').replace(/^[^/]*\//, '/'))))
|
|
134
|
+
const pieces: string[] = []
|
|
135
|
+
let cursor = 0
|
|
136
|
+
WEB_URL.lastIndex = 0
|
|
137
|
+
for (let match = WEB_URL.exec(text); match; match = WEB_URL.exec(text)) {
|
|
138
|
+
let token = match[0]
|
|
139
|
+
// A prose/Markdown closing parenthesis ends the token; balanced URL path
|
|
140
|
+
// parentheses remain part of it. Explicit angle/quote wrapping supplies its own end.
|
|
141
|
+
if (!/[<"'`“‘]/.test(text[match.index - 1] ?? '')) {
|
|
142
|
+
let depth = 0
|
|
143
|
+
for (let i = 0; i < token.length; i++) {
|
|
144
|
+
if (token[i] === '(') depth++
|
|
145
|
+
else if (token[i] === ')' && depth-- === 0) { token = token.slice(0, i); break }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
try { new URL(token) } catch { continue }
|
|
149
|
+
pieces.push(redact(text.slice(cursor, match.index)), hideHomeInUrl(token))
|
|
150
|
+
cursor = match.index + token.length
|
|
151
|
+
WEB_URL.lastIndex = cursor
|
|
152
|
+
}
|
|
153
|
+
pieces.push(redact(text.slice(cursor)))
|
|
154
|
+
return pieces.join('')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `/Users/<name>` (any letter case, any number of leading slashes). In code spans, code blocks and command fields it
|
|
159
|
+
* reads as the visible marker `[home]`, keeping the rest of the path, so a reader sees where the path was and that it
|
|
160
|
+
* was changed. The result is a display, not a runnable command: `~` was not used because a quoted `'~/x'` does not
|
|
161
|
+
* expand, so it would read as exact while meaning something else. In a web URL's query it reads `~`, which keeps the
|
|
162
|
+
* URL well formed. `/Users/Shared` is not a person's home and stays.
|
|
163
|
+
*/
|
|
164
|
+
const HOME_FOLDER = new RegExp(`(^|[^\\p{L}\\p{N}_.-])/+${USERS}/(?!Shared(?:/|$|[${STOP}]))[^${STOP}/&?#=]+`, 'gu')
|
|
165
|
+
/** The same inside a URL query, percent-encoded: `%2FUsers%2Fjane` is `~`. */
|
|
166
|
+
const ENCODED_HOME_FOLDER = /(?:%2F)+users%2F(?!shared(?:%2F|[&#\s]|$))[^%&?#=\s/]+/gi
|
|
167
|
+
/** What a hidden home folder reads as in code and commands. */
|
|
168
|
+
export const HOME_MARKER = '[home]'
|
|
169
|
+
|
|
170
|
+
/** Every `/Users/<name>` in this text as the visible `[home]` marker; nothing else changes. */
|
|
171
|
+
export function markHomeFolder(text: string): string {
|
|
172
|
+
if (typeof text !== 'string' || !text) return text
|
|
173
|
+
return text.replace(HOME_FOLDER, (_match, before: string) => before + HOME_MARKER)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A web URL exactly as written, except a home folder in its query or fragment (`?path=/Users/jane/a.pdf`). */
|
|
177
|
+
function hideHomeInUrl(url: string): string {
|
|
178
|
+
const at = url.search(/[?#]/)
|
|
179
|
+
if (at < 0) return url
|
|
180
|
+
return url.slice(0, at) + url.slice(at).replace(HOME_FOLDER, (_match, before: string) => before + '~').replace(ENCODED_HOME_FOLDER, '~')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Code in Markdown: fenced blocks (``` or ~~~, closed by a fence of the same kind at least as long, or by the end of the
|
|
185
|
+
* text) and inline code spans (a run of backticks closed by a run of the same length, within one paragraph).
|
|
186
|
+
*/
|
|
187
|
+
function codeRanges(text: string): Array<[number, number]> {
|
|
188
|
+
const ranges: Array<[number, number]> = []
|
|
189
|
+
let open: { start: number; mark: string; length: number } | null = null
|
|
190
|
+
for (let pos = 0; pos <= text.length;) {
|
|
191
|
+
const newline = text.indexOf('\n', pos)
|
|
192
|
+
const end = newline < 0 ? text.length : newline
|
|
193
|
+
const line = text.slice(pos, end)
|
|
194
|
+
if (!open) {
|
|
195
|
+
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line)
|
|
196
|
+
if (fence && !(fence[1][0] === '`' && line.slice(fence[0].length).includes('`'))) open = { start: pos, mark: fence[1][0]!, length: fence[1].length }
|
|
197
|
+
} else {
|
|
198
|
+
const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)
|
|
199
|
+
if (close && close[1][0] === open.mark && close[1].length >= open.length) { ranges.push([open.start, end]); open = null }
|
|
200
|
+
}
|
|
201
|
+
if (newline < 0) break
|
|
202
|
+
pos = newline + 1
|
|
203
|
+
}
|
|
204
|
+
if (open) ranges.push([open.start, text.length])
|
|
205
|
+
const inFence = (at: number) => ranges.some(([s, e]) => at >= s && at < e)
|
|
206
|
+
const INLINE = /(?<!`)(`+)(?!`)([\s\S]*?[^`])\1(?!`)/g
|
|
207
|
+
const spans: Array<[number, number]> = []
|
|
208
|
+
for (let m = INLINE.exec(text); m; m = INLINE.exec(text)) {
|
|
209
|
+
if (inFence(m.index)) { INLINE.lastIndex = m.index + 1; continue }
|
|
210
|
+
if (/\n[ \t]*\n/.test(m[2]!)) { INLINE.lastIndex = m.index + m[1]!.length; continue }
|
|
211
|
+
spans.push([m.index, m.index + m[0].length])
|
|
212
|
+
}
|
|
213
|
+
return [...ranges, ...spans].sort((x, y) => x[0] - y[0])
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Every private local path in this text. In prose a path becomes its base name (Markdown links to a local file become
|
|
218
|
+
* their words). In code spans and code blocks the path is kept and only the home folder is replaced, by the visible
|
|
219
|
+
* `[home]` marker: the code is then a display of the command, not the command. Web URLs are untouched except a home folder in their query; relative paths, site
|
|
220
|
+
* paths and system paths (`/dev/null`, `/usr/bin/env`, `/tmp/x`) stay as written.
|
|
221
|
+
*/
|
|
222
|
+
export function redactLocalPaths(text: string): string {
|
|
223
|
+
if (typeof text !== 'string' || !text) return text
|
|
224
|
+
const before = codeRanges(text)
|
|
225
|
+
const inCode = (at: number) => before.some(([s, e]) => at >= s && at < e)
|
|
226
|
+
const linked = text.replace(LINK, (match: string, _bang: string, words: string, rawDest: string, offset: number) => {
|
|
227
|
+
if (inCode(offset)) return match // a link written inside code is code
|
|
228
|
+
const dest = rawDest.startsWith('<') ? rawDest.slice(1, -1).trim() : rawDest.trim()
|
|
229
|
+
let target: string | null = null
|
|
230
|
+
if (LOCAL_SCHEME_DEST.test(dest)) target = dest.replace(LOCAL_SCHEME_DEST, '').replace(/^[^/]*\//, '/')
|
|
231
|
+
else if (LOCAL_DEST.test(dest)) target = dest
|
|
232
|
+
if (target === null) return match // a web URL, a relative path or a site path: left exactly as written
|
|
233
|
+
const name = baseName(target)
|
|
234
|
+
const label = words.trim()
|
|
235
|
+
if (!label) return name
|
|
236
|
+
// `[app.ts](/abs/app.ts#L42)` keeps its line: the words named the file, the anchor was in the link.
|
|
237
|
+
const anchor = /:\d+$/.exec(name)?.[0] ?? ''
|
|
238
|
+
return anchor && !/:\d+$/.test(label) && label.split('/').pop() === name.slice(0, -anchor.length) ? label + anchor : label
|
|
239
|
+
})
|
|
240
|
+
const pieces: string[] = []
|
|
241
|
+
let cursor = 0
|
|
242
|
+
for (const [start, end] of codeRanges(linked)) {
|
|
243
|
+
if (start < cursor) continue
|
|
244
|
+
pieces.push(redactOutsideWebURLs(linked.slice(cursor, start)), markHomeFolder(linked.slice(start, end)))
|
|
245
|
+
cursor = end
|
|
246
|
+
}
|
|
247
|
+
pieces.push(redactOutsideWebURLs(linked.slice(cursor)))
|
|
248
|
+
return pieces.join('')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fields whose words are a command or a reason (a waiting permission's command, a failure): the path is kept and only
|
|
253
|
+
* the home folder reads `[home]`, so `rm -rf /Users/<name>/Documents` reads `rm -rf [home]/Documents`, never the
|
|
254
|
+
* harmless-looking `rm -rf Documents`. Control Web labels that line as a display. The exact command is on the approval
|
|
255
|
+
* card (`/api/session-questions`), which is not redacted.
|
|
256
|
+
*/
|
|
257
|
+
export const COMMAND_KEYS: ReadonlySet<string> = new Set(['waiting_detail', 'failure'])
|
|
258
|
+
|
|
259
|
+
/** A copy of this JSON value with every string redacted (keys are left alone). Command fields keep their paths. */
|
|
260
|
+
export function redactLocalPathsDeep<T>(value: T): T {
|
|
261
|
+
const walk = (item: unknown, command: boolean): unknown => {
|
|
262
|
+
if (typeof item === 'string') return command ? markHomeFolder(item) : redactLocalPaths(item)
|
|
263
|
+
if (Array.isArray(item)) return item.map(entry => walk(entry, command))
|
|
264
|
+
if (item && typeof item === 'object') {
|
|
265
|
+
const out: Record<string, unknown> = {}
|
|
266
|
+
for (const [key, entry] of Object.entries(item as Record<string, unknown>)) out[key] = walk(entry, command || COMMAND_KEYS.has(key))
|
|
267
|
+
return out
|
|
268
|
+
}
|
|
269
|
+
return item
|
|
270
|
+
}
|
|
271
|
+
return walk(value, false) as T
|
|
272
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import { countsAsMeeting, readCalendar } from './calendar-source.js'
|
|
3
|
+
import { callPython, pythonBridgeAvailable } from './python-bridge.js'
|
|
3
4
|
import { redactSecrets } from './lens-gist-engines.js'
|
|
4
5
|
import { type MeetingAskCalendarMatch, type MeetingAskOrientation } from '../../shared/meeting-ask.js'
|
|
5
6
|
import { domainNames, isSafeDomainName } from './domains.js'
|
|
@@ -8,13 +9,19 @@ import { domainLabel } from './domain-label.js'
|
|
|
8
9
|
import { AskError } from './live-cos-ask.js'
|
|
9
10
|
export const meetingDomains = () => domainNames(resolveCosOperationsDir()).map(id => ({id,label:domainLabel(id)}))
|
|
10
11
|
export const emptyOrientation = (): MeetingAskOrientation => ({ revision: 0, domain: null, calendar: null })
|
|
12
|
+
type CandidateEvent = { match: MeetingAskCalendarMatch; event: any }
|
|
11
13
|
export function calendarCandidates(raw: unknown, startedAt: number | null): MeetingAskCalendarMatch[] {
|
|
14
|
+
return candidateEvents(raw, startedAt).map(c => c.match)
|
|
15
|
+
}
|
|
16
|
+
// QA W23: the same prep filter as Next up and Today (countsAsMeeting), so a declined meeting or a family flight that
|
|
17
|
+
// covers the recording's start is never offered as "this meeting".
|
|
18
|
+
function candidateEvents(raw: unknown, startedAt: number | null): CandidateEvent[] {
|
|
12
19
|
if (startedAt === null || !Number.isFinite(startedAt)) return []
|
|
13
20
|
const data = raw as any
|
|
14
21
|
if (!data || data.is_stale || !Array.isArray(data.today_events)) return []
|
|
15
22
|
const seen = new Set<string>()
|
|
16
|
-
return data.today_events.slice(0, 200).flatMap((event: any) => {
|
|
17
|
-
if (!event || event
|
|
23
|
+
return data.today_events.slice(0, 200).flatMap((event: any): CandidateEvent[] => {
|
|
24
|
+
if (!event || !countsAsMeeting(event) || typeof event.title !== 'string' || event.title.length > 500) return []
|
|
18
25
|
const start = Date.parse(event.start_time), end = Date.parse(event.end_time)
|
|
19
26
|
// Match the recording's start, never whichever event happens to be current when Ask runs.
|
|
20
27
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end - start > 24 * 3600000 || startedAt < start - 5 * 60000 || startedAt >= end) return []
|
|
@@ -22,14 +29,41 @@ export function calendarCandidates(raw: unknown, startedAt: number | null): Meet
|
|
|
22
29
|
if (!title || /\booo\b|out of office|vacation|\bpto\b/i.test(title)) return []
|
|
23
30
|
const match: MeetingAskCalendarMatch = { id: createHash('sha256').update(JSON.stringify([title, start, end])).digest('hex').slice(0, 24), title, start: new Date(start).toISOString(), end: new Date(end).toISOString(), ...(typeof event.domain === 'string' && isSafeDomainName(event.domain) ? { suggestedDomain: event.domain } : {}) }
|
|
24
31
|
if (seen.has(match.id)) return []
|
|
25
|
-
seen.add(match.id); return [match]
|
|
32
|
+
seen.add(match.id); return [{ match, event }]
|
|
26
33
|
}).slice(0, 5)
|
|
27
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* QA W21: snapshot rows carry domain: null (the bridge's classifier is Python people context), so ask the bridge's
|
|
37
|
+
* `calendar-classify` for the candidates that lack one: the exact rule its own calendar answer uses
|
|
38
|
+
* (classify_event_by_attendees on title, attendee names and calendar name). Names only over stdin, never addresses or
|
|
39
|
+
* domains. No bridge, an old bridge, a slow one or a bad answer: no suggestion, as before.
|
|
40
|
+
*/
|
|
41
|
+
export async function classifyWithBridge(rows: Array<{ title: string; attendees: string[]; calendar_name: string | null }>): Promise<Array<string | null> | null> {
|
|
42
|
+
if (!rows.length || !pythonBridgeAvailable()) return null
|
|
43
|
+
const out = await callPython(['calendar-classify'], 3000, JSON.stringify({ events: rows })) as any
|
|
44
|
+
if (!out || !Array.isArray(out.results) || out.results.length !== rows.length) return null
|
|
45
|
+
return out.results.map((r: any) => (r && typeof r.domain === 'string' ? r.domain : null))
|
|
46
|
+
}
|
|
47
|
+
async function withSuggestedDomains(found: CandidateEvent[], classify = classifyWithBridge): Promise<MeetingAskCalendarMatch[]> {
|
|
48
|
+
const need = found.filter(f => !f.match.suggestedDomain)
|
|
49
|
+
if (!need.length) return found.map(f => f.match)
|
|
50
|
+
let domains: Array<string | null> | null = null
|
|
51
|
+
try {
|
|
52
|
+
domains = await classify(need.map(f => ({
|
|
53
|
+
title: f.match.title,
|
|
54
|
+
attendees: Array.isArray(f.event.attendees) ? f.event.attendees.filter((n: unknown): n is string => typeof n === 'string' && !n.includes('@')).slice(0, 50) : [],
|
|
55
|
+
calendar_name: typeof f.event.calendar_name === 'string' ? f.event.calendar_name : null,
|
|
56
|
+
})))
|
|
57
|
+
} catch { domains = null }
|
|
58
|
+
if (!domains) return found.map(f => f.match)
|
|
59
|
+
need.forEach((f, i) => { const d = domains![i]; if (typeof d === 'string' && isSafeDomainName(d)) f.match.suggestedDomain = d })
|
|
60
|
+
return found.map(f => f.match)
|
|
61
|
+
}
|
|
28
62
|
export async function meetingCalendar(startedAt: number | null): Promise<{ candidates: MeetingAskCalendarMatch[]; notices: string[] }> {
|
|
29
63
|
if (startedAt === null) return { candidates: [], notices: ['Calendar matching is available for a live recording. Choose a domain for this meeting.'] }
|
|
30
64
|
try {
|
|
31
|
-
const raw = await
|
|
32
|
-
const candidates =
|
|
65
|
+
const raw = (await readCalendar(5000)).data as any
|
|
66
|
+
const candidates = await withSuggestedDomains(candidateEvents(raw, startedAt))
|
|
33
67
|
return { candidates, notices: candidates.length ? [] : [raw?.error || raw?.is_stale ? 'Calendar context is unavailable or stale. Choose a domain manually.' : 'No calendar event matches this recording. Choose a domain manually.'] }
|
|
34
68
|
} catch { return { candidates: [], notices: ['Calendar context is unavailable. Choose a domain manually.'] } }
|
|
35
69
|
}
|
|
@@ -21,6 +21,8 @@ export interface MeetingAskRunBudget {
|
|
|
21
21
|
deadlineMs?: number;
|
|
22
22
|
}
|
|
23
23
|
export const MEETING_ASK_DEFAULT_BUDGET = { maxOutputTokens: 1400, maxThinkingTokens: 0, deadlineMs: 45000 } as const;
|
|
24
|
+
export const MEETING_ASK_TERMINATION_PROOF_MS = 4500;
|
|
25
|
+
export const MEETING_ASK_LATE_PROOF_POLL_MS = 250;
|
|
24
26
|
/** The child environment's token limits for a budget; the default is exactly '1400' and '0'. */
|
|
25
27
|
export function meetingAskTokenEnv(budget?: MeetingAskRunBudget): { CLAUDE_CODE_MAX_OUTPUT_TOKENS: string; MAX_THINKING_TOKENS: string } {
|
|
26
28
|
const output = budget?.maxOutputTokens ?? MEETING_ASK_DEFAULT_BUDGET.maxOutputTokens;
|
|
@@ -28,7 +30,9 @@ export function meetingAskTokenEnv(budget?: MeetingAskRunBudget): { CLAUDE_CODE_
|
|
|
28
30
|
return { CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(output + thinking), MAX_THINKING_TOKENS: String(thinking) };
|
|
29
31
|
}
|
|
30
32
|
export class AskProcessError extends Error {
|
|
31
|
-
constructor(message: string, readonly terminationUnconfirmed = false
|
|
33
|
+
constructor(message: string, readonly terminationUnconfirmed = false,
|
|
34
|
+
/** Runner-issued proof: resolves only after the original child closed and its group is absent. Never an expiry. */
|
|
35
|
+
readonly terminationConfirmed?: Promise<void>) { super(message); }
|
|
32
36
|
}
|
|
33
37
|
export function runMeetingAskModel(model: MeetingAskModel, prompt: string, system: string, signal?: AbortSignal, adapter?: {
|
|
34
38
|
workspace: string;
|
|
@@ -57,16 +61,17 @@ budget?: MeetingAskRunBudget): Promise<{
|
|
|
57
61
|
const stdin = adapter?.stdin ?? (provider === 'claude' ? prompt : `${system}\n\n${prompt}`);
|
|
58
62
|
return new Promise((resolve, reject) => {
|
|
59
63
|
const child = spawn(binary.path, args, { cwd: workspace, detached: true, stdio: ['pipe', 'pipe', 'pipe'], env: { ...lensGistChildEnv(binary.path), ...meetingAskTokenEnv(budget) } });
|
|
64
|
+
const processGroupId = child.pid;
|
|
60
65
|
let stdout = '', failure: string | null = null, done = false, closeSeen = false, code: number | null = null;
|
|
61
66
|
let killTimer: NodeJS.Timeout | undefined, proofTimer: NodeJS.Timeout | undefined;
|
|
62
|
-
const kill = (sig: NodeJS.Signals) => { if (
|
|
67
|
+
const kill = (sig: NodeJS.Signals) => { if (processGroupId)
|
|
63
68
|
try {
|
|
64
|
-
process.kill(-
|
|
69
|
+
process.kill(-processGroupId, sig);
|
|
65
70
|
}
|
|
66
71
|
catch { } };
|
|
67
|
-
const alive = () => { if (!
|
|
72
|
+
const alive = () => { if (!processGroupId)
|
|
68
73
|
return false; try {
|
|
69
|
-
process.kill(-
|
|
74
|
+
process.kill(-processGroupId, 0);
|
|
70
75
|
return true;
|
|
71
76
|
}
|
|
72
77
|
catch (e) {
|
|
@@ -99,8 +104,23 @@ budget?: MeetingAskRunBudget): Promise<{
|
|
|
99
104
|
signal?.removeEventListener('abort', abort);
|
|
100
105
|
if (!unconfirmed && !adapter)
|
|
101
106
|
rmSync(workspace, { recursive: true, force: true });
|
|
102
|
-
if (unconfirmed)
|
|
103
|
-
|
|
107
|
+
if (unconfirmed) {
|
|
108
|
+
// Answer the request within its shutdown budget, but keep observing the
|
|
109
|
+
// original child. An unconfirmed exit is not permission to admit another
|
|
110
|
+
// provider. This watcher is passive: no later signal can hit a reused PID.
|
|
111
|
+
let prove!: () => void;
|
|
112
|
+
const terminationConfirmed = new Promise<void>(resolve => { prove = resolve; });
|
|
113
|
+
const proof = setInterval(() => {
|
|
114
|
+
// A missing/reused/permission-denied group alone is not enough: the
|
|
115
|
+
// close event belongs to this original child, and ESRCH proves absence.
|
|
116
|
+
if (!closeSeen || !processGroupId || alive()) return;
|
|
117
|
+
clearInterval(proof);
|
|
118
|
+
if (!adapter) try { rmSync(workspace, { recursive: true, force: true }); } catch { /* proof does not depend on scratch cleanup */ }
|
|
119
|
+
prove();
|
|
120
|
+
}, MEETING_ASK_LATE_PROOF_POLL_MS);
|
|
121
|
+
proof.unref();
|
|
122
|
+
return reject(new AskProcessError('child_exit_unconfirmed', true, terminationConfirmed));
|
|
123
|
+
}
|
|
104
124
|
if (failure || code !== 0)
|
|
105
125
|
return reject(new AskProcessError(failure ?? 'provider_failed'));
|
|
106
126
|
try {
|
|
@@ -135,7 +155,7 @@ budget?: MeetingAskRunBudget): Promise<{
|
|
|
135
155
|
const start = Date.now();
|
|
136
156
|
proofTimer = setInterval(() => { if (closeSeen && !alive())
|
|
137
157
|
finish();
|
|
138
|
-
else if (Date.now() - start >
|
|
158
|
+
else if (Date.now() - start > MEETING_ASK_TERMINATION_PROOF_MS)
|
|
139
159
|
finish(true); }, 50);
|
|
140
160
|
};
|
|
141
161
|
const abort = () => stop('cancelled');
|
|
@@ -143,11 +163,11 @@ budget?: MeetingAskRunBudget): Promise<{
|
|
|
143
163
|
signal?.addEventListener('abort', abort, { once: true });
|
|
144
164
|
if (signal?.aborted)
|
|
145
165
|
abort();
|
|
146
|
-
child.stdout.on('data', (b: Buffer) => { stdout += b.toString(); if (stdout.length > 1000000)
|
|
166
|
+
child.stdout.on('data', (b: Buffer) => { if (done) return; stdout += b.toString(); if (stdout.length > 1000000)
|
|
147
167
|
stop('output_limit'); });
|
|
148
168
|
child.stderr.on('data', () => { }); // Never surface provider credentials/raw errors.
|
|
149
169
|
child.stdin.on('error', () => stop('provider_input_failed'));
|
|
150
|
-
child.on('error', () => { failure = 'provider_unavailable';
|
|
170
|
+
child.on('error', () => { failure = 'provider_unavailable'; finish(); });
|
|
151
171
|
child.on('close', exit => { closeSeen = true; code = exit; if (failure) {
|
|
152
172
|
if (!alive())
|
|
153
173
|
finish();
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
transcribeHighQuality,
|
|
17
17
|
type HighQualityTranscriptionResult,
|
|
18
18
|
type WhisperWord,
|
|
19
|
+
isWhisperHqTimeout,
|
|
19
20
|
} from './whisper-local.js'
|
|
20
21
|
import { asrFrontEnd } from './stt-live.js'
|
|
21
22
|
import { measureSpeechSeconds } from './vad-silero.js'
|
|
@@ -412,6 +413,7 @@ export const __progressiveHqTesting = {
|
|
|
412
413
|
segmentRangeIsComplete,
|
|
413
414
|
nextMissingSealedSegment,
|
|
414
415
|
progressiveFailureIdentity,
|
|
416
|
+
recordProgressiveFailure,
|
|
415
417
|
resolveProgressiveHqPolicy,
|
|
416
418
|
progressiveSessionIsIdle,
|
|
417
419
|
yieldIdleProgressiveSessions,
|
|
@@ -1091,6 +1093,30 @@ function yieldIdleProgressiveSessions(activeSessionId: string, now = Date.now())
|
|
|
1091
1093
|
}
|
|
1092
1094
|
}
|
|
1093
1095
|
|
|
1096
|
+
/** Attempts a progressive checkpoint gets before it waits for the save-time job. */
|
|
1097
|
+
const PROGRESSIVE_MAX_ATTEMPTS = 3
|
|
1098
|
+
const PROGRESSIVE_RETRY_BACKOFF_MS = [60_000, 120_000, 300_000]
|
|
1099
|
+
|
|
1100
|
+
/** S1 (2026-11-09): what one failed checkpoint decode means for its retries.
|
|
1101
|
+
* A timeout is not retried. The retry would run the same audio on the same CPU lane at the
|
|
1102
|
+
* same background QoS against the same 240 s ceiling: 10/26 to 11/09, 112 segments timed out
|
|
1103
|
+
* and 85 of them timed out on all three attempts, so 178 of 290 timeouts (about 12 h of
|
|
1104
|
+
* CPU during meetings) were retries. The checkpoint is disposable; the save-time job decodes
|
|
1105
|
+
* that window. Any other failure keeps the 60/120/300 s backoff. Returns whether it is terminal. */
|
|
1106
|
+
function recordProgressiveFailure(
|
|
1107
|
+
state: Pick<ProgressiveSessionState, 'failedIdentity' | 'failedAttempts' | 'retryAfter'>,
|
|
1108
|
+
identity: string,
|
|
1109
|
+
error: unknown,
|
|
1110
|
+
now = Date.now(),
|
|
1111
|
+
): { terminal: boolean; timedOut: boolean } {
|
|
1112
|
+
const timedOut = isWhisperHqTimeout(error)
|
|
1113
|
+
const attempts = state.failedIdentity === identity ? state.failedAttempts + 1 : 1
|
|
1114
|
+
state.failedAttempts = timedOut ? Math.max(attempts, PROGRESSIVE_MAX_ATTEMPTS) : attempts
|
|
1115
|
+
state.failedIdentity = identity
|
|
1116
|
+
state.retryAfter = now + PROGRESSIVE_RETRY_BACKOFF_MS[Math.min(PROGRESSIVE_RETRY_BACKOFF_MS.length - 1, state.failedAttempts - 1)]
|
|
1117
|
+
return { terminal: state.failedAttempts >= PROGRESSIVE_MAX_ATTEMPTS, timedOut }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1094
1120
|
function segmentRangeIsComplete(segment: BatchSegment, completedIndices: number[]): boolean {
|
|
1095
1121
|
const completed = new Set(completedIndices)
|
|
1096
1122
|
for (let index = segment.startChunkIdx; index <= segment.endChunkIdx; index++) {
|
|
@@ -1111,7 +1137,7 @@ function nextMissingSealedSegment(
|
|
|
1111
1137
|
if (!cached) {
|
|
1112
1138
|
const identity = progressiveFailureIdentity(state.audioDir, segment, previousText)
|
|
1113
1139
|
if (!identity) return null
|
|
1114
|
-
if (state.failedIdentity === identity && (state.failedAttempts >=
|
|
1140
|
+
if (state.failedIdentity === identity && (state.failedAttempts >= PROGRESSIVE_MAX_ATTEMPTS || Date.now() < state.retryAfter)) return null
|
|
1115
1141
|
return { segment, previousText }
|
|
1116
1142
|
}
|
|
1117
1143
|
previousText = cached.text
|
|
@@ -1191,12 +1217,13 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
|
|
|
1191
1217
|
if ((error as Error)?.name !== 'AbortError') {
|
|
1192
1218
|
const identity = progressiveFailureIdentity(state.audioDir, pending.segment, pending.previousText)
|
|
1193
1219
|
if (!identity) return
|
|
1194
|
-
|
|
1195
|
-
state.failedIdentity = identity
|
|
1196
|
-
state.retryAfter = Date.now() + [60_000, 120_000, 300_000][Math.min(2, state.failedAttempts - 1)]
|
|
1220
|
+
const outcome = recordProgressiveFailure(state, identity, error)
|
|
1197
1221
|
console.warn(
|
|
1198
1222
|
`[meeting-hq-checkpoint] ${sessionId} ${pending.segment.startChunkIdx}-${pending.segment.endChunkIdx}: `
|
|
1199
|
-
+ `${error instanceof Error ? error.message : String(error)}
|
|
1223
|
+
+ `${error instanceof Error ? error.message : String(error)}`
|
|
1224
|
+
+ (outcome.terminal
|
|
1225
|
+
? (outcome.timedOut ? ' (timeout: not retried; the save-time job decodes this window)' : ' (attempts exhausted; the save-time job decodes this window)')
|
|
1226
|
+
: ` (attempt ${state.failedAttempts} of ${PROGRESSIVE_MAX_ATTEMPTS})`),
|
|
1200
1227
|
)
|
|
1201
1228
|
}
|
|
1202
1229
|
} finally {
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import { importedLibraryMonths, supersededDayCounts } from './imported-library-rows.js'
|
|
43
43
|
import { getMeetingStore } from './meeting-store.js'
|
|
44
44
|
import { COS_SCRIPTS_DIR, callPython, contextSourceAvailable, pythonBridgeAvailable } from './python-bridge.js'
|
|
45
|
+
import { readCalendar } from './calendar-source.js'
|
|
45
46
|
import { resolveProviderWorkDir } from './launch-dir.js'
|
|
46
47
|
|
|
47
48
|
/** Highest stamped message number in the active era: live sessions, the day
|
|
@@ -113,8 +114,9 @@ export async function probeContext(): Promise<ContextProbe | null> {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
export async function probeCalendar(): Promise<CalendarProbe | null> {
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
const read = await readCalendar(8_000)
|
|
118
|
+
if (read.source === 'none' || (read.source === 'bridge' && !pythonBridgeAvailable())) return null
|
|
119
|
+
const data = read.data as { meetings_today_count?: unknown; today_events?: unknown; data_source?: unknown } | null
|
|
118
120
|
if (!data || typeof data !== 'object') return null
|
|
119
121
|
const today = typeof data.meetings_today_count === 'number'
|
|
120
122
|
? data.meetings_today_count
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 7.0.4 W1: is this request COS Control's own helper on this Mac, rather than a browser?
|
|
2
|
+
//
|
|
3
|
+
// WHY. S1 redaction turns paths in a session's words into base names before they leave the Mac. COS Control
|
|
4
|
+
// (0.5.378 and 0.5.379, unchanged) recognises that a Work prompt it sent has ARRIVED by comparing the first 60
|
|
5
|
+
// letters and digits of its own, unredacted prompt with the user turns of `GET /api/agent-sessions/:p/:id?turns=N`
|
|
6
|
+
// (WorkProgress.promptArrival, through the helper's `session-recent-replies`). A prompt that opens with a path then
|
|
7
|
+
// never matched: no replies shown, Continue refused, a Cursor note stuck at queued.
|
|
8
|
+
//
|
|
9
|
+
// WHO. The helper calls `http://127.0.0.1:<port>` with the X-COS-Token header, from URLSession: no Origin, no
|
|
10
|
+
// Referer, no Sec-Fetch-* metadata, and a CFNetwork user agent. A browser always sends fetch metadata
|
|
11
|
+
// (Chrome 76, Firefox 90, Safari 16.4 and later) and a `Mozilla/` user agent; Control Web signs in with a cookie,
|
|
12
|
+
// not the header; a phone is never on loopback; a `tailscale serve` hop arrives on loopback but carries proxy
|
|
13
|
+
// headers (isLoopbackSocket refuses it). Any other local process holding the token can already read the files.
|
|
14
|
+
import type { Request, Response } from 'express'
|
|
15
|
+
import { API_AUTH_BY_LOCAL } from './api-auth.js'
|
|
16
|
+
import { isLoopbackSocket } from './network-policy.js'
|
|
17
|
+
|
|
18
|
+
const BROWSER_HEADERS = ['origin', 'referer', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'] as const
|
|
19
|
+
|
|
20
|
+
/** True only for a header-token request on this Mac's loopback, not proxied, carrying nothing a browser sends. */
|
|
21
|
+
export function isNativeMacCaller(req: Pick<Request, 'headers' | 'socket'>, res: Pick<Response, 'locals'>): boolean {
|
|
22
|
+
if (res.locals?.[API_AUTH_BY_LOCAL] !== 'token') return false
|
|
23
|
+
if (!isLoopbackSocket(req)) return false
|
|
24
|
+
const headers = req.headers ?? {}
|
|
25
|
+
for (const name of BROWSER_HEADERS) if (headers[name] !== undefined) return false
|
|
26
|
+
const agent = headers['user-agent']
|
|
27
|
+
if (typeof agent === 'string' && /\bMozilla\//.test(agent)) return false
|
|
28
|
+
return true
|
|
29
|
+
}
|