@gotcos/glasses-server 6.99.29 → 7.0.7
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 +18 -0
- package/CHANGELOG.md +475 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- 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 +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- 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/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- 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/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- 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 +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- 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 +13 -0
- package/server/routes/agent-session-stream.ts +16 -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/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +79 -9
- package/server/web-control/app.js +1543 -65
- package/server/web-control/control.css +37 -6
- package/server/web-control/index.html +8 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +51 -6
- package/server/web-control/panes/cos-chat.js +268 -16
- package/server/web-control/panes/meetings.css +23 -4
- package/server/web-control/panes/meetings.js +25 -9
- package/server/web-control/panes/memories.css +18 -3
- package/server/web-control/panes/memories.js +130 -50
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.css +20 -4
- package/server/web-control/panes/questions.js +127 -26
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +50 -10
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +49 -18
- package/server/web-control/panes/today-actions.js +217 -50
- package/server/web-control/panes/work.css +11 -1
- package/server/web-control/panes/work.js +10 -2
- package/server/web-control/shell.js +20 -14
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link cards (preview engine phase 1, PLAN_preview_engine_2026-11-08 4f). A Markdown link in a reply to a local file
|
|
3
|
+
* becomes a card where it sits. 40% of sessions link a local file; 3,389 links in 14 days, none of which worked.
|
|
4
|
+
*
|
|
5
|
+
* This module is pure apart from filesystem reads in `resolveLinkTarget`:
|
|
6
|
+
* - `linkTokens`: the same bounded scanner as the inline-image tokens, for `[label](target)`. Code fences, inline
|
|
7
|
+
* code, indented blocks and comments stay inert. Web URLs and #anchors are not local and stay as written.
|
|
8
|
+
* - `splitLineAnchor`: `file.ts:42`, `:42:7`, `:42-50` and `#L42` become `line`, never part of the name.
|
|
9
|
+
* - `resolveLinkTarget`: the row's cwd first, then the workspace root (21% of relative links need the root), then
|
|
10
|
+
* authorized only inside the workspace or a Settings extra folder, and never a denied secret name.
|
|
11
|
+
* - `previewKind`: what the viewer does with a name.
|
|
12
|
+
*
|
|
13
|
+
* Paths never leave the server: a card carries a base name, the link's words and an opaque id.
|
|
14
|
+
*/
|
|
15
|
+
import { realpathSync, statSync } from 'node:fs'
|
|
16
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { isDeniedSecretPath } from './secret-path-guard.js'
|
|
19
|
+
|
|
20
|
+
export const LINK_CARDS_PER_TURN = 20
|
|
21
|
+
export const SESSION_OUTPUT_LINK_ID = /^al_[a-f0-9]{64}$/
|
|
22
|
+
/** The marker a card leaves in the projected text: `[label](cos-file:N)`, N the index into the turn's outputLinks. */
|
|
23
|
+
export const LINK_MARKER = /\[([^\]\n]*)\]\(cos-file:(\d{1,2})\)/g
|
|
24
|
+
|
|
25
|
+
export type PreviewKind = 'image' | 'pdf' | 'html' | 'markdown' | 'text' | 'code' | 'table' | 'document' | 'slides' | 'sheet'
|
|
26
|
+
| 'audio' | 'video' | 'archive' | 'folder' | 'file'
|
|
27
|
+
/** `secret_file`: the 7.0.4 shared deny list (secret-path-guard.ts) refused the name, as written or canonically. */
|
|
28
|
+
export type LinkReason = 'outside_workspace' | 'missing' | 'unavailable' | 'secret_file'
|
|
29
|
+
|
|
30
|
+
/** A link card. Only a base name, the link's words and an opaque id travel; never a Mac path. */
|
|
31
|
+
export interface SessionOutputLink {
|
|
32
|
+
id: string
|
|
33
|
+
label: string
|
|
34
|
+
name: string
|
|
35
|
+
kind: PreviewKind
|
|
36
|
+
type: string
|
|
37
|
+
size?: number
|
|
38
|
+
line?: number
|
|
39
|
+
state: 'available' | 'unavailable'
|
|
40
|
+
reason?: LinkReason
|
|
41
|
+
/** The words the card shows instead (SECRET_FILE_NOTICE), set only with `secret_file`. */
|
|
42
|
+
notice?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LinkToken { label: string; target: string; start: number; end: number }
|
|
46
|
+
|
|
47
|
+
const KINDS: Array<[RegExp, PreviewKind, string]> = [
|
|
48
|
+
[/\.(png|jpe?g|webp|hei[cf]|avif)$/i, 'image', 'Image'],
|
|
49
|
+
[/\.gif$/i, 'image', 'GIF image'],
|
|
50
|
+
[/\.pdf$/i, 'pdf', 'PDF'],
|
|
51
|
+
[/\.html?$/i, 'html', 'HTML page'],
|
|
52
|
+
[/\.(md|markdown|mdx)$/i, 'markdown', 'Markdown'],
|
|
53
|
+
[/\.csv$/i, 'table', 'CSV'],
|
|
54
|
+
[/\.tsv$/i, 'table', 'TSV'],
|
|
55
|
+
[/\.(txt|log|text)$/i, 'text', 'Text'],
|
|
56
|
+
[/\.json$/i, 'code', 'JSON'],
|
|
57
|
+
[/\.jsonl$/i, 'code', 'JSON lines'],
|
|
58
|
+
[/\.ya?ml$/i, 'code', 'YAML'],
|
|
59
|
+
[/\.toml$/i, 'code', 'TOML'],
|
|
60
|
+
[/\.xml$/i, 'code', 'XML'],
|
|
61
|
+
// An SVG can carry script, so it is shown as its source, never as a picture.
|
|
62
|
+
[/\.svg$/i, 'code', 'SVG source'],
|
|
63
|
+
[/\.(ts|tsx|mts|cts)$/i, 'code', 'TypeScript'],
|
|
64
|
+
[/\.(js|jsx|mjs|cjs)$/i, 'code', 'JavaScript'],
|
|
65
|
+
[/\.py$/i, 'code', 'Python'],
|
|
66
|
+
[/\.swift$/i, 'code', 'Swift'],
|
|
67
|
+
[/\.(sh|zsh|bash)$/i, 'code', 'Shell script'],
|
|
68
|
+
[/\.(css|scss)$/i, 'code', 'CSS'],
|
|
69
|
+
[/\.(rb|go|rs|java|kt|c|h|cc|cpp|hpp|m|mm|sql|lua|php)$/i, 'code', 'Source code'],
|
|
70
|
+
[/\.(plist|ini|conf|cfg|properties)$/i, 'code', 'Settings file'],
|
|
71
|
+
[/\.docx?$/i, 'document', 'Word document'],
|
|
72
|
+
[/\.(pages|rtf)$/i, 'document', 'Document'],
|
|
73
|
+
[/\.xlsx?$/i, 'sheet', 'Excel workbook'],
|
|
74
|
+
[/\.numbers$/i, 'sheet', 'Spreadsheet'],
|
|
75
|
+
[/\.pptx?$/i, 'slides', 'PowerPoint deck'],
|
|
76
|
+
[/\.key$/i, 'slides', 'Keynote deck'],
|
|
77
|
+
[/\.(wav|mp3|m4a|aac|flac|aiff?)$/i, 'audio', 'Audio'],
|
|
78
|
+
[/\.(mp4|mov|webm|m4v)$/i, 'video', 'Video'],
|
|
79
|
+
[/\.(zip|tar|gz|tgz|bz2|xz|7z)$/i, 'archive', 'Archive'],
|
|
80
|
+
[/\.ehpk$/i, 'archive', 'EHPK package'],
|
|
81
|
+
]
|
|
82
|
+
export function previewKind(name: string): { kind: PreviewKind; type: string } {
|
|
83
|
+
for (const [re, kind, type] of KINDS) if (re.test(name)) return { kind, type }
|
|
84
|
+
const ext = /\.([a-z0-9]{1,8})$/i.exec(name)
|
|
85
|
+
return { kind: 'file', type: ext ? ext[1].toUpperCase() + ' file' : 'File' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The base name a person sees: no directories, no control characters, at most 120 characters. */
|
|
89
|
+
export function baseName(source: string): string {
|
|
90
|
+
const base = source.split(/[\\/]/).filter(Boolean).pop() || ''
|
|
91
|
+
const clean = base.replace(/[\u0000-\u001f\u007f]/g, '').trim()
|
|
92
|
+
return (clean || 'File').slice(0, 120)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A link's words, made safe to show: a label that is itself an absolute or home path becomes its base name, so
|
|
97
|
+
* `[/Users/x/plan.md](/Users/x/plan.md)` never puts the path in front of anyone. Anything else is kept (120 chars).
|
|
98
|
+
*/
|
|
99
|
+
export function linkLabel(label: string, name: string): string {
|
|
100
|
+
const clean = label.replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
|
|
101
|
+
if (!clean) return name
|
|
102
|
+
if (/^(?:file:\/\/|~\/|\/)/.test(clean) || /(?:^|[\s(`'"])(?:\/Users\/|\/private\/|\/Volumes\/|\/var\/folders\/)/.test(clean)) return baseName(clean.replace(/^file:\/\//, ''))
|
|
103
|
+
return clean.slice(0, 120)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The words a link card shows in the reply. A label that is the file's own name keeps the line the link points at,
|
|
108
|
+
* so `[app.ts](/Users/x/app.ts#L42)` reads `app.ts:42`, as the 7.0.4 redactor wrote it. Prose labels stay as written.
|
|
109
|
+
*/
|
|
110
|
+
export function linkCardWords(label: string, name: string, target: string): string {
|
|
111
|
+
const words = linkLabel(label, name)
|
|
112
|
+
const { path, line } = splitLineAnchor(target)
|
|
113
|
+
if (!line) return words
|
|
114
|
+
const own = baseName(path.replace(/^file:\/\//i, ''))
|
|
115
|
+
return words === own || words === name ? words + ':' + line : words
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const LINK_SCAN_CHARS = 128 * 1024
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `[label](target)` tokens in reply text, never `` (images have their own scanner). Bounded like the image
|
|
122
|
+
* scanner: inert inside fences, inline code, indented blocks and comments; the label has no brackets or newline; the
|
|
123
|
+
* target is angle-wrapped (spaces allowed) or balanced parentheses on one line. A trailing ` "title"` is dropped.
|
|
124
|
+
*/
|
|
125
|
+
export function linkTokens(raw: string, scanChars = LINK_SCAN_CHARS): LinkToken[] {
|
|
126
|
+
const text = raw.slice(0, scanChars)
|
|
127
|
+
const out: LinkToken[] = []
|
|
128
|
+
let fence = '', inline = 0
|
|
129
|
+
for (let i = 0; i < text.length; i++) {
|
|
130
|
+
if (i === 0 || text[i - 1] === '\n') {
|
|
131
|
+
if (!fence && /^(?: {4}| {0,3}\t)/.test(text.slice(i, i + 4))) { i = text.indexOf('\n', i); if (i < 0) break; continue }
|
|
132
|
+
const m = /^( {0,3})(`{3,}|~{3,})/.exec(text.slice(i, i + 80))
|
|
133
|
+
if (m) { if (!fence) fence = m[2]; else if (m[2][0] === fence[0] && m[2].length >= fence.length) fence = ''; i = text.indexOf('\n', i); if (i < 0) break; continue }
|
|
134
|
+
}
|
|
135
|
+
if (fence) continue
|
|
136
|
+
if (text[i] === '\\') { i++; continue }
|
|
137
|
+
if (text[i] === '`') { let n = 1; while (text[i + n] === '`') n++; if (!inline) inline = n; else if (inline === n) inline = 0; i += n - 1; continue }
|
|
138
|
+
if (inline) continue
|
|
139
|
+
if (text.startsWith('<!--', i)) { const end = text.indexOf('-->', i + 4); if (end < 0) break; i = end + 2; continue }
|
|
140
|
+
if (text[i] !== '[' || text[i - 1] === '!') continue
|
|
141
|
+
const close = text.indexOf(']', i + 1)
|
|
142
|
+
if (close < 0 || close - i > 250 || text[close + 1] !== '(') continue
|
|
143
|
+
const label = text.slice(i + 1, close)
|
|
144
|
+
if (label.includes('[') || label.includes('\n')) continue
|
|
145
|
+
let j = close + 2, target = '', valid = false
|
|
146
|
+
if (text[j] === '<') {
|
|
147
|
+
const end = text.indexOf('>', j + 1)
|
|
148
|
+
if (end >= 0 && text[end + 1] === ')' && !text.slice(j + 1, end).includes('\n')) { target = text.slice(j + 1, end); j = end + 2; valid = true }
|
|
149
|
+
} else {
|
|
150
|
+
const start = j; let depth = 1
|
|
151
|
+
while (j < text.length && j - start <= 4096) {
|
|
152
|
+
if (text[j] === '\n') break
|
|
153
|
+
if (text[j] === '(') depth++
|
|
154
|
+
if (text[j] === ')' && --depth === 0) { target = text.slice(start, j); j++; valid = true; break }
|
|
155
|
+
j++
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!valid || !target || target.length > 4096 || /[\u0000-\u001f\u007f]/.test(target)) continue
|
|
159
|
+
const titled = /^(\S+)\s+"[^"]*"$/.exec(target)
|
|
160
|
+
if (titled) target = titled[1]
|
|
161
|
+
out.push({ label, target, start: i, end: j }); i = j - 1
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A target that names a local file: a path, `~/...` or `file://`. Web URLs, mail, anchors and network paths are not. */
|
|
167
|
+
export function isLocalTarget(target: string): boolean {
|
|
168
|
+
if (!target || target.startsWith('#') || target.startsWith('//') || target.includes('\\')) return false
|
|
169
|
+
if (/^file:\/\//i.test(target)) return true
|
|
170
|
+
// `server.ts:42` is a path with a line, not a scheme.
|
|
171
|
+
return !/^[a-z][a-z0-9+.-]*:/i.test(splitLineAnchor(target).path)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `x.ts:42`, `x.ts:42:7`, `x.ts:42-50`, `x.ts#L42`, `x.ts#L42-L50` → the path and line 42. */
|
|
175
|
+
export function splitLineAnchor(target: string): { path: string; line?: number } {
|
|
176
|
+
let m = /^(.*?)#L(\d{1,7})(?:C\d+)?(?:-L?\d{1,7})?$/.exec(target)
|
|
177
|
+
if (m && m[1]) return { path: m[1], line: Number(m[2]) || undefined }
|
|
178
|
+
m = /^(.*?):(\d{1,7})(?::\d{1,5}|-\d{1,7})?$/.exec(target)
|
|
179
|
+
if (m && m[1] && !/:$/.test(m[1])) return { path: m[1], line: Number(m[2]) || undefined }
|
|
180
|
+
const hash = target.indexOf('#')
|
|
181
|
+
return { path: hash > 0 ? target.slice(0, hash) : target }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface LinkRoots {
|
|
185
|
+
/** The session's canonical workspace, or null (broad or unknown). */
|
|
186
|
+
workspace: string | null
|
|
187
|
+
/** Canonical Settings extra folders. */
|
|
188
|
+
extra: readonly string[]
|
|
189
|
+
home: string
|
|
190
|
+
}
|
|
191
|
+
export type Resolved =
|
|
192
|
+
| { state: 'available'; path: string; root: string; name: string; isDir: boolean; size?: number }
|
|
193
|
+
| { state: 'unavailable'; reason: LinkReason; name: string }
|
|
194
|
+
|
|
195
|
+
export function insideRoot(root: string, path: string): boolean {
|
|
196
|
+
const rel = relative(root, path)
|
|
197
|
+
return rel !== '' && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)
|
|
198
|
+
}
|
|
199
|
+
/** The allowed root holding a canonical path, or null. */
|
|
200
|
+
export function authorizedRoot(path: string, roots: LinkRoots): string | null {
|
|
201
|
+
if (roots.workspace && insideRoot(roots.workspace, path)) return roots.workspace
|
|
202
|
+
for (const r of roots.extra) if (insideRoot(r, path)) return r
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function expandTarget(target: string, home: string): string | null {
|
|
207
|
+
if (/^file:\/\//i.test(target)) { try { return fileURLToPath(target) } catch { return null } }
|
|
208
|
+
if (target === '~') return home
|
|
209
|
+
if (target.startsWith('~/')) return join(home, target.slice(2))
|
|
210
|
+
return target
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where a link points, and whether COS may show it. Bases: the row's cwd, then the workspace root. A relative link
|
|
215
|
+
* that exists under neither is missing (when it would sit inside an allowed root) or outside. The canonical file
|
|
216
|
+
* decides containment, so a link out of the root is outside. A denied secret name is refused before any read, by its
|
|
217
|
+
* written name and by its canonical name.
|
|
218
|
+
*/
|
|
219
|
+
export function resolveLinkTarget(rawTarget: string, bases: readonly (string | null | undefined)[], roots: LinkRoots): Resolved {
|
|
220
|
+
const { path: written } = splitLineAnchor(rawTarget)
|
|
221
|
+
const variants = [written]
|
|
222
|
+
if (/%[0-9a-f]{2}/i.test(written)) { try { const d = decodeURIComponent(written); if (d !== written) variants.push(d) } catch { /* keep as written */ } }
|
|
223
|
+
// A percent-encoded name reads as its words ("Q3 plan.md", not "Q3%20plan.md").
|
|
224
|
+
const name = baseName(variants[variants.length - 1])
|
|
225
|
+
const lexicals: string[] = []
|
|
226
|
+
for (const v of variants) {
|
|
227
|
+
const expanded = expandTarget(v, roots.home)
|
|
228
|
+
if (!expanded) continue
|
|
229
|
+
if (isAbsolute(expanded)) lexicals.push(resolve(expanded))
|
|
230
|
+
else for (const b of bases) if (b && isAbsolute(b)) lexicals.push(resolve(b, expanded))
|
|
231
|
+
}
|
|
232
|
+
if (!lexicals.length) return { state: 'unavailable', reason: 'outside_workspace', name }
|
|
233
|
+
if (lexicals.some(p => isDeniedSecretPath(p))) return { state: 'unavailable', reason: 'secret_file', name }
|
|
234
|
+
for (const lexical of lexicals) {
|
|
235
|
+
let canonical: string
|
|
236
|
+
try { canonical = realpathSync(lexical) } catch { continue }
|
|
237
|
+
const root = authorizedRoot(canonical, roots)
|
|
238
|
+
if (!root) return { state: 'unavailable', reason: 'outside_workspace', name }
|
|
239
|
+
if (isDeniedSecretPath(canonical)) return { state: 'unavailable', reason: 'secret_file', name }
|
|
240
|
+
try {
|
|
241
|
+
const st = statSync(canonical)
|
|
242
|
+
if (st.isDirectory()) return { state: 'available', path: canonical, root, name, isDir: true }
|
|
243
|
+
if (!st.isFile()) return { state: 'unavailable', reason: 'missing', name }
|
|
244
|
+
// 7.0.4 QA's sent-file rule: a file with another hard link may be a secret under this name. Pictures are exempt
|
|
245
|
+
// (they are normalised to a JPEG, never served as they are).
|
|
246
|
+
if (st.nlink > 1 && !/\.(png|jpe?g|webp|hei[cf]|avif)$/i.test(canonical)) return { state: 'unavailable', reason: 'secret_file', name }
|
|
247
|
+
return { state: 'available', path: canonical, root, name, isDir: false, size: st.size }
|
|
248
|
+
} catch { return { state: 'unavailable', reason: 'missing', name } }
|
|
249
|
+
}
|
|
250
|
+
// Nothing there. Say missing only for a path that would be inside an allowed root (by its real parent folder).
|
|
251
|
+
const wouldBeInside = lexicals.some(p => {
|
|
252
|
+
if (authorizedRoot(p, roots)) return true
|
|
253
|
+
try { return authorizedRoot(join(realpathSync(resolve(p, '..')), baseName(p)), roots) !== null } catch { return false }
|
|
254
|
+
})
|
|
255
|
+
return { state: 'unavailable', reason: wouldBeInside ? 'missing' : 'outside_workspace', name }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Text with every card marker turned back into its words. Used when previews are off. Pure. */
|
|
259
|
+
export function withoutLinkMarkers(text: string): string {
|
|
260
|
+
return text.replace(LINK_MARKER, '$1')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The sentence a reply ends with when it links more files than LINK_CARDS_PER_TURN (added after its last word). */
|
|
264
|
+
export function linkOverflowSentence(extra: number): string {
|
|
265
|
+
return extra + (extra === 1 ? ' more linked file is not shown as a card.' : ' more linked files are not shown as cards.')
|
|
266
|
+
}
|
|
267
|
+
const LINK_OVERFLOW = /\n\n\d{1,6} more linked files? (?:is|are) not shown as (?:a )?cards?\./g
|
|
268
|
+
/** The reply cap can cut the sentence: `\n\n23 more linked fi…`, at the end or just before the scan-limit line. */
|
|
269
|
+
const LINK_OVERFLOW_CUT = /\n\n(\d{1,6})([^\n…]*)…(?=\n\[Reply shortened at the image preview scan limit\.\]$|$)/
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* QA 7.0.5 (agent 4 C-N1): previews off sends no count, so the overflow sentence goes as well as the markers. A cut
|
|
273
|
+
* sentence goes only when what is left is exactly the start of that sentence, so a reply's own words stay. Pure.
|
|
274
|
+
*/
|
|
275
|
+
export function withoutLinkCards(text: string): string {
|
|
276
|
+
const out = withoutLinkMarkers(text).replace(LINK_OVERFLOW, '')
|
|
277
|
+
const cut = LINK_OVERFLOW_CUT.exec(out)
|
|
278
|
+
if (!cut || !linkOverflowSentence(Number(cut[1])).startsWith(cut[1] + cut[2])) return out
|
|
279
|
+
return out.slice(0, cut.index) + out.slice(cut.index + cut[0].length)
|
|
280
|
+
}
|
|
@@ -154,6 +154,16 @@ export function sessionSteerEnabled(env: NodeJS.ProcessEnv = process.env): boole
|
|
|
154
154
|
return triStateFlag(env, 'COS_SESSION_STEER', false)
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* `COS_STEER_MIDTURN` (steer mid-turn, Miles 2026-11-07): a turn parked at a busy Claude desk
|
|
159
|
+
* session goes INTO the running turn through its peer inbox at the drain, and lands at its next
|
|
160
|
+
* tool step, instead of waiting for the turn to end. Default ON; `0` is the 6.99.33 behaviour.
|
|
161
|
+
* Independent of `COS_SESSION_STEER`, which only answers a client that asks for `mode: "steer"`.
|
|
162
|
+
*/
|
|
163
|
+
export function steerMidturnEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
164
|
+
return triStateFlag(env, 'COS_STEER_MIDTURN', true)
|
|
165
|
+
}
|
|
166
|
+
|
|
157
167
|
/**
|
|
158
168
|
* Reasons whose outcome COS cannot establish. Such a row is never resent and is kept for
|
|
159
169
|
* UNKNOWN_DELIVERY_RETENTION_MS rather than 30 minutes, because the phone has to be able to
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Today's top text was counts ("Three agents are working, with 30 more sessions open"). This writes two or three plain
|
|
5
5
|
* sentences about what the sessions are building toward, what is moving, what waits on Miles and what comes next, from
|
|
6
6
|
* what an open Today already read: session titles, states and how long, each session's last line, what needs him, and
|
|
7
|
-
* the Work cards in progress. Haiku writes it through the isolated text runner (claude-text-runner.ts: no tools, MCP,
|
|
7
|
+
* the Work cards in progress. Sonnet (7.0.1; Haiku before) writes it through the isolated text runner (claude-text-runner.ts: no tools, MCP,
|
|
8
8
|
* hooks or session, `--model` always passed, so it can never become Opus).
|
|
9
9
|
*
|
|
10
10
|
* HOW IT STOPS (every model caller answers this; .claude/rules/gotchas.md "Every new claude -p caller"):
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* set, the Settings toggle "Write the Today summary with a model" decides (today-brief-settings.json in the data
|
|
13
13
|
* folder). Neither set: OFF. The server has no clean private-build signal, so OFF is the default for everyone.
|
|
14
14
|
* - CAPS: a generation only when an open Today asks (POST /api/today/brief), only when the trimmed inputs' fingerprint
|
|
15
|
-
* changed since the last summary, at most one start every 10 minutes, at most
|
|
16
|
-
* counted BEFORE the call, so a call that ran was spent whatever became of it.
|
|
15
|
+
* changed since the last summary, at most one start every 10 minutes (7.0.5: a reconnect may skip that once per 10 minutes), at most "Summaries per day" starts a local day (7.0.5: a setting, 1 to 100, default 100; was 60). A start is
|
|
16
|
+
* counted BEFORE the call, so a call that ran was spent whatever became of it. 7.0.1 FORCED REFRESH (the Today
|
|
17
|
+
* refresh button, `force: true`): skips the 10-minute interval and the "inputs changed" check, at most one forced
|
|
18
|
+
* start every 30 s, and still inside the same daily cap, the breaker, the switch, recording and drain.
|
|
17
19
|
* - BREAKER: 3 failures in a row (a CLI error, a timeout, an empty or guarded answer) stop it for 1 hour; the next
|
|
18
20
|
* start after that hour is one trial, and a failed trial reopens it for another hour.
|
|
19
21
|
* - NOT WHILE: a meeting is recording or the server is draining (6.99.26 QA). Those, the switch, the breaker, the cap
|
|
@@ -37,11 +39,36 @@ import { localDay } from './local-day.js'
|
|
|
37
39
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
38
40
|
import { securePrivateDirectory } from './secure-user-config.js'
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
/**
|
|
43
|
+
* 7.0.1 (Miles 11/07, "It might be better to use sonnet for that summary generation"): Sonnet, was Haiku. The isolated
|
|
44
|
+
* runner passes it as `--model sonnet`, the CLI's alias for the current Sonnet (measured 11/07: it resolved to
|
|
45
|
+
* claude-sonnet-5-5 through runClaudeText).
|
|
46
|
+
*
|
|
47
|
+
* COST, measured 11/07 with a full-size prompt (40 sessions, about 3.6k input tokens, about 100 output) through the
|
|
48
|
+
* runner's own argv with JSON output: Sonnet $0.0156 to $0.0180 a call, Haiku $0.0008. About twenty times more a call.
|
|
49
|
+
* 7.0.5: the cap is "Summaries per day", default 100 (forced refreshes included), so the worst day is about $1.80 where Haiku's was about
|
|
50
|
+
* $0.05; the 10-minute interval and "only when the inputs changed" keep a normal day far below the cap. On a Claude
|
|
51
|
+
* subscription the CLI draws plan usage rather than billing; the figures are the CLI's own API-price estimate.
|
|
52
|
+
*/
|
|
53
|
+
export const TODAY_BRIEF_MODEL = 'sonnet'
|
|
41
54
|
export const TODAY_BRIEF_EFFORT = 'low'
|
|
42
55
|
export const TODAY_BRIEF_TIMEOUT_MS = 30_000
|
|
43
56
|
export const TODAY_BRIEF_MIN_INTERVAL_MS = 10 * 60 * 1000
|
|
44
|
-
|
|
57
|
+
/**
|
|
58
|
+
* 7.0.5 (Miles 11/08): "Summaries per day" is a setting, default 100, 1 to 100, saved with the switch in
|
|
59
|
+
* today-brief-settings.json and enforced here. Was a fixed 60. A saved value outside the range is clamped.
|
|
60
|
+
*/
|
|
61
|
+
export const TODAY_BRIEF_DAILY_CAP = 100
|
|
62
|
+
export const TODAY_BRIEF_DAILY_CAP_MIN = 1
|
|
63
|
+
export const TODAY_BRIEF_DAILY_CAP_MAX = 100
|
|
64
|
+
/**
|
|
65
|
+
* 7.0.5: a client whose read failed and then recovered may say `reason: "reconnect"`: that one start skips the 10-minute
|
|
66
|
+
* interval (never the "inputs unchanged" check, the breaker, the switch or the daily cap). At most one such skip per
|
|
67
|
+
* this, so a client cannot use it to skip the interval again and again.
|
|
68
|
+
*/
|
|
69
|
+
export const TODAY_BRIEF_RECONNECT_MIN_MS = 10 * 60 * 1000
|
|
70
|
+
/** 7.0.1: a forced refresh (the Today refresh button) at most once per this, on top of the daily cap. */
|
|
71
|
+
export const TODAY_BRIEF_FORCE_MIN_MS = 30_000
|
|
45
72
|
export const TODAY_BRIEF_BREAKER_FAILURES = 3
|
|
46
73
|
export const TODAY_BRIEF_BREAKER_MS = 60 * 60 * 1000
|
|
47
74
|
/** The fenced data block's bound. Four characters a token: about 3,000 tokens, with the system prompt about 3,300. */
|
|
@@ -54,7 +81,7 @@ export const TODAY_BRIEF_SETTINGS_FILE = 'today-brief-settings.json'
|
|
|
54
81
|
|
|
55
82
|
export type TodayBriefState = 'fresh' | 'stale' | 'off' | 'failed'
|
|
56
83
|
/** Why there is no fresh summary: the switch, a cap, the breaker, a failed call, or nothing to write about. */
|
|
57
|
-
export type TodayBriefReason = 'switch_off' | 'interval' | 'daily_cap' | 'paused' | 'failed' | 'empty_inputs' | 'not_asked' | 'recording' | 'maintenance'
|
|
84
|
+
export type TodayBriefReason = 'switch_off' | 'interval' | 'daily_cap' | 'paused' | 'failed' | 'empty_inputs' | 'not_asked' | 'recording' | 'maintenance' | 'refresh_throttled'
|
|
58
85
|
export type TodayBriefSwitchSource = 'env' | 'settings' | 'default'
|
|
59
86
|
|
|
60
87
|
export interface TodayBriefView {
|
|
@@ -70,6 +97,8 @@ export interface TodayBriefView {
|
|
|
70
97
|
/** The switch as resolved now, and what set it. */
|
|
71
98
|
enabled: boolean
|
|
72
99
|
source: TodayBriefSwitchSource
|
|
100
|
+
/** 7.0.1: with reason `refresh_throttled`, how long until a forced refresh may start. */
|
|
101
|
+
retryAfterMs?: number
|
|
73
102
|
}
|
|
74
103
|
|
|
75
104
|
// ── inputs ─────────────────────────────────────────────────────────────────
|
|
@@ -484,6 +513,10 @@ interface BriefFile {
|
|
|
484
513
|
count: number
|
|
485
514
|
/** When the last generation started (the interval). */
|
|
486
515
|
lastStartAt: number
|
|
516
|
+
/** 7.0.1: when the last FORCED generation started (TODAY_BRIEF_FORCE_MIN_MS). */
|
|
517
|
+
lastForceAt: number
|
|
518
|
+
/** 7.0.5: when the last start that used the reconnect skip began (TODAY_BRIEF_RECONNECT_MIN_MS). */
|
|
519
|
+
lastReconnectAt: number
|
|
487
520
|
/** Failures in a row, and when the breaker opened. */
|
|
488
521
|
failures: number
|
|
489
522
|
openedAt: number
|
|
@@ -496,7 +529,7 @@ interface BriefFile {
|
|
|
496
529
|
lastFailCode: string
|
|
497
530
|
cache: BriefCache | null
|
|
498
531
|
}
|
|
499
|
-
const emptyFile = (day: string): BriefFile => ({ schema: 1, day, count: 0, lastStartAt: 0, failures: 0, openedAt: 0, lastAskFingerprint: '', lastOpener: '', lastFailAt: 0, lastFailCode: '', cache: null })
|
|
532
|
+
const emptyFile = (day: string): BriefFile => ({ schema: 1, day, count: 0, lastStartAt: 0, lastForceAt: 0, lastReconnectAt: 0, failures: 0, openedAt: 0, lastAskFingerprint: '', lastOpener: '', lastFailAt: 0, lastFailCode: '', cache: null })
|
|
500
533
|
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0)
|
|
501
534
|
|
|
502
535
|
function readJson(file: string): unknown {
|
|
@@ -521,6 +554,8 @@ function parseFile(raw: unknown, day: string): BriefFile {
|
|
|
521
554
|
day: typeof raw.day === 'string' ? raw.day : day,
|
|
522
555
|
count: Math.floor(num(raw.count)),
|
|
523
556
|
lastStartAt: num(raw.lastStartAt),
|
|
557
|
+
lastForceAt: num(raw.lastForceAt),
|
|
558
|
+
lastReconnectAt: num(raw.lastReconnectAt),
|
|
524
559
|
failures: Math.floor(num(raw.failures)),
|
|
525
560
|
openedAt: num(raw.openedAt),
|
|
526
561
|
lastAskFingerprint: typeof raw.lastAskFingerprint === 'string' ? raw.lastAskFingerprint : '',
|
|
@@ -554,28 +589,44 @@ export interface TodayBriefDeps {
|
|
|
554
589
|
}
|
|
555
590
|
|
|
556
591
|
/** The Settings status line's facts: paused until, today's cap reached, or the last call failed. */
|
|
557
|
-
export type TodayBriefStatus = { kind: 'paused'; until: string } | { kind: 'daily_cap'; count: number } | { kind: 'failed'; at: string; code: string } | null
|
|
558
|
-
export interface TodayBriefSettings { enabled: boolean; source: TodayBriefSwitchSource; saved: boolean | null; status?: TodayBriefStatus
|
|
592
|
+
export type TodayBriefStatus = { kind: 'paused'; until: string } | { kind: 'daily_cap'; count: number; cap: number } | { kind: 'failed'; at: string; code: string } | null
|
|
593
|
+
export interface TodayBriefSettings { enabled: boolean; source: TodayBriefSwitchSource; saved: boolean | null; status?: TodayBriefStatus
|
|
594
|
+
/** 7.0.5: "Summaries per day" (TODAY_BRIEF_DAILY_CAP_MIN to _MAX). */
|
|
595
|
+
dailyCap: number }
|
|
596
|
+
/** 7.0.5: GET|PUT /api/today/brief/settings/cap. */
|
|
597
|
+
export interface TodayBriefCapView { dailyCap: number; min: number; max: number; default: number; usedToday: number }
|
|
598
|
+
|
|
599
|
+
/** A whole number of summaries a day, 1 to 100. Anything that is not a finite number is the default. */
|
|
600
|
+
export function clampTodayBriefDailyCap(value: unknown): number {
|
|
601
|
+
const n = typeof value === 'number' ? value : NaN
|
|
602
|
+
if (!Number.isFinite(n)) return TODAY_BRIEF_DAILY_CAP
|
|
603
|
+
return Math.min(TODAY_BRIEF_DAILY_CAP_MAX, Math.max(TODAY_BRIEF_DAILY_CAP_MIN, Math.round(n)))
|
|
604
|
+
}
|
|
559
605
|
|
|
560
606
|
export interface TodayBrief {
|
|
561
607
|
/** The cached summary, no generation (GET). */
|
|
562
608
|
peek(): TodayBriefView
|
|
563
|
-
/** Why nothing could be generated now, checked BEFORE the server's board and sub-agent reads; null when it may.
|
|
564
|
-
|
|
609
|
+
/** Why nothing could be generated now, checked BEFORE the server's board and sub-agent reads; null when it may.
|
|
610
|
+
* 7.0.1 `force` (the refresh button): the 10-minute interval does not apply; one forced start per 30 s does. */
|
|
611
|
+
gate(opts?: { force?: boolean; reconnect?: boolean }): TodayBriefReason | null
|
|
565
612
|
/** A gated ask: the page's rows only, no reads and no call. Fresh while the page's rows match the cached summary's. */
|
|
566
613
|
askGated(pageInputs: TodayBriefInputs, reason: TodayBriefReason): TodayBriefView
|
|
567
614
|
/** An open Today's ask with what it read (POST). Generates only when every gate allows; never throws for a gate.
|
|
568
615
|
* `pageFingerprint` is the page's rows alone, kept with a new summary for later gated asks. */
|
|
569
|
-
ask(inputs: TodayBriefInputs, pageFingerprint?: string): Promise<TodayBriefView>
|
|
616
|
+
ask(inputs: TodayBriefInputs, pageFingerprint?: string, opts?: { force?: boolean; reconnect?: boolean }): Promise<TodayBriefView>
|
|
570
617
|
settings(): TodayBriefSettings
|
|
571
618
|
setEnabled(enabled: boolean): TodayBriefSettings
|
|
619
|
+
/** 7.0.5: "Summaries per day". */
|
|
620
|
+
cap(): TodayBriefCapView
|
|
621
|
+
setDailyCap(value: number): TodayBriefCapView
|
|
572
622
|
}
|
|
573
623
|
|
|
574
624
|
export function createTodayBrief(deps: TodayBriefDeps): TodayBrief {
|
|
575
625
|
const now = deps.now ?? Date.now
|
|
576
626
|
const env = deps.env ?? (() => process.env)
|
|
577
627
|
const minInterval = deps.minIntervalMs ?? TODAY_BRIEF_MIN_INTERVAL_MS
|
|
578
|
-
|
|
628
|
+
/** A test's fixed cap, else the saved setting (read live), else 100. */
|
|
629
|
+
const dailyCap = (): number => deps.dailyCap ?? savedCap() ?? TODAY_BRIEF_DAILY_CAP
|
|
579
630
|
let state: BriefFile | null = null
|
|
580
631
|
let inflight: Promise<void> | null = null
|
|
581
632
|
|
|
@@ -598,6 +649,17 @@ export function createTodayBrief(deps: TodayBriefDeps): TodayBrief {
|
|
|
598
649
|
const raw = readJson(deps.settingsFile)
|
|
599
650
|
return record(raw) && raw.schema === 1 && typeof raw.enabled === 'boolean' ? raw.enabled : null
|
|
600
651
|
}
|
|
652
|
+
const savedCap = (): number | null => {
|
|
653
|
+
const raw = readJson(deps.settingsFile)
|
|
654
|
+
return record(raw) && raw.schema === 1 && typeof raw.dailyCap === 'number' && Number.isFinite(raw.dailyCap) ? clampTodayBriefDailyCap(raw.dailyCap) : null
|
|
655
|
+
}
|
|
656
|
+
/** The settings file keeps both fields: writing one never drops the other. */
|
|
657
|
+
const writeSettings = (patch: { enabled?: boolean; dailyCap?: number }) => {
|
|
658
|
+
const enabled = patch.enabled ?? savedSetting()
|
|
659
|
+
const cap = patch.dailyCap ?? savedCap()
|
|
660
|
+
securePrivateDirectory(dirname(deps.settingsFile))
|
|
661
|
+
durableAtomicWriteFileSync(deps.settingsFile, JSON.stringify({ schema: 1, ...(enabled !== null ? { enabled } : {}), ...(cap !== null ? { dailyCap: cap } : {}) }) + '\n', { mode: 0o600 })
|
|
662
|
+
}
|
|
601
663
|
const resolveSwitch = (): { enabled: boolean; source: TodayBriefSwitchSource; saved: boolean | null } => {
|
|
602
664
|
const saved = savedSetting()
|
|
603
665
|
const raw = (env().COS_TODAY_BRIEF_LLM ?? '').trim()
|
|
@@ -611,7 +673,16 @@ export function createTodayBrief(deps: TodayBriefDeps): TodayBrief {
|
|
|
611
673
|
const within = (since: number, ms: number) => since >= 0 && since < ms
|
|
612
674
|
const breakerOpen = (f: BriefFile) => f.failures >= TODAY_BRIEF_BREAKER_FAILURES && within(now() - f.openedAt, TODAY_BRIEF_BREAKER_MS)
|
|
613
675
|
|
|
676
|
+
const forceWait = (f: BriefFile): number => {
|
|
677
|
+
const since = now() - f.lastForceAt
|
|
678
|
+
return f.lastForceAt && within(since, TODAY_BRIEF_FORCE_MIN_MS) ? TODAY_BRIEF_FORCE_MIN_MS - since : 0
|
|
679
|
+
}
|
|
614
680
|
const view = (fingerprint: string, reason?: TodayBriefReason): TodayBriefView => {
|
|
681
|
+
const base = viewOf(fingerprint, reason)
|
|
682
|
+
if (reason !== 'refresh_throttled' || !base.enabled) return base
|
|
683
|
+
return { ...base, reason, retryAfterMs: Math.max(1, Math.ceil(forceWait(load()))) }
|
|
684
|
+
}
|
|
685
|
+
const viewOf = (fingerprint: string, reason?: TodayBriefReason): TodayBriefView => {
|
|
615
686
|
const sw = resolveSwitch()
|
|
616
687
|
if (!sw.enabled) return { text: '', at: null, basis: '', model: TODAY_BRIEF_MODEL, state: 'off', reason: 'switch_off', enabled: false, source: sw.source }
|
|
617
688
|
const f = load(), cache = todaysCache(f)
|
|
@@ -624,11 +695,14 @@ export function createTodayBrief(deps: TodayBriefDeps): TodayBrief {
|
|
|
624
695
|
}
|
|
625
696
|
}
|
|
626
697
|
|
|
627
|
-
const generate = (inputs: TodayBriefInputs, fingerprint: string, pageFingerprint: string, lease: { release(): void } | null): Promise<void> => {
|
|
698
|
+
const generate = (inputs: TodayBriefInputs, fingerprint: string, pageFingerprint: string, lease: { release(): void } | null, force = false, reconnectSkip = false): Promise<void> => {
|
|
628
699
|
const f = load()
|
|
629
|
-
// Counted before the call: a call that started was spent.
|
|
700
|
+
// Counted before the call: a call that started was spent. A forced start counts against the same daily cap.
|
|
630
701
|
f.count += 1
|
|
631
702
|
f.lastStartAt = now()
|
|
703
|
+
if (force) f.lastForceAt = now()
|
|
704
|
+
// 7.0.5: only a start that actually skipped the interval spends the reconnect allowance.
|
|
705
|
+
if (reconnectSkip) f.lastReconnectAt = now()
|
|
632
706
|
save()
|
|
633
707
|
const prompt = buildTodayBriefPrompt(inputs, now())
|
|
634
708
|
const started = now()
|
|
@@ -664,26 +738,35 @@ export function createTodayBrief(deps: TodayBriefDeps): TodayBrief {
|
|
|
664
738
|
.finally(() => { try { lease?.release() } catch { /* released by its owner */ } })
|
|
665
739
|
}
|
|
666
740
|
/** The brief's own gates plus the server's (recording, drain). */
|
|
667
|
-
|
|
741
|
+
/** 7.0.5: a reconnect may skip the interval once per TODAY_BRIEF_RECONNECT_MIN_MS. */
|
|
742
|
+
const reconnectAllowed = (f: BriefFile) => !(f.lastReconnectAt && within(now() - f.lastReconnectAt, TODAY_BRIEF_RECONNECT_MIN_MS))
|
|
743
|
+
/** Whether this start would skip the interval because of a reconnect (the interval would hold it, the allowance is there). */
|
|
744
|
+
const reconnectSkips = (f: BriefFile, force: boolean, reconnect: boolean) =>
|
|
745
|
+
reconnect && !force && !!f.lastStartAt && within(now() - f.lastStartAt, minInterval) && reconnectAllowed(f)
|
|
746
|
+
const gate = (f: BriefFile, force = false, reconnect = false): TodayBriefReason | null => {
|
|
668
747
|
if (!resolveSwitch().enabled) return 'switch_off'
|
|
669
748
|
if (inflight) return null // an ask waits on the call already running rather than answering stale
|
|
670
749
|
const blocked = deps.blocked?.() ?? null
|
|
671
750
|
if (blocked) return blocked
|
|
672
751
|
if (breakerOpen(f)) return 'paused'
|
|
673
|
-
if (f.count >= dailyCap) return 'daily_cap'
|
|
674
|
-
|
|
752
|
+
if (f.count >= dailyCap()) return 'daily_cap'
|
|
753
|
+
// 7.0.1: the refresh button skips the 10-minute interval, never the cap, the breaker or the switch, and has its own
|
|
754
|
+
// floor so a held-down button cannot spend the day's cap in a minute.
|
|
755
|
+
if (force) return forceWait(f) > 0 ? 'refresh_throttled' : null
|
|
756
|
+
if (f.lastStartAt && within(now() - f.lastStartAt, minInterval) && !reconnectSkips(f, force, reconnect)) return 'interval'
|
|
675
757
|
return null
|
|
676
758
|
}
|
|
677
759
|
const settingsView = (): TodayBriefSettings => {
|
|
678
760
|
const sw = resolveSwitch()
|
|
679
|
-
if (!sw.enabled) return sw
|
|
761
|
+
if (!sw.enabled) return { ...sw, dailyCap: dailyCap() }
|
|
680
762
|
const f = load()
|
|
681
763
|
const status: TodayBriefStatus = breakerOpen(f) ? { kind: 'paused', until: new Date(f.openedAt + TODAY_BRIEF_BREAKER_MS).toISOString() }
|
|
682
|
-
: f.count >= dailyCap ? { kind: 'daily_cap', count: f.count }
|
|
764
|
+
: f.count >= dailyCap() ? { kind: 'daily_cap', count: f.count, cap: dailyCap() }
|
|
683
765
|
: f.failures > 0 && f.lastFailAt ? { kind: 'failed', at: new Date(f.lastFailAt).toISOString(), code: f.lastFailCode || 'failed' }
|
|
684
766
|
: null
|
|
685
|
-
return { ...sw, status }
|
|
767
|
+
return { ...sw, status, dailyCap: dailyCap() }
|
|
686
768
|
}
|
|
769
|
+
const capView = (): TodayBriefCapView => ({ dailyCap: dailyCap(), min: TODAY_BRIEF_DAILY_CAP_MIN, max: TODAY_BRIEF_DAILY_CAP_MAX, default: TODAY_BRIEF_DAILY_CAP, usedToday: load().count })
|
|
687
770
|
const remember = (f: BriefFile, fingerprint: string, opener: string) => {
|
|
688
771
|
if (f.lastAskFingerprint !== fingerprint || f.lastOpener !== opener) { f.lastAskFingerprint = fingerprint; f.lastOpener = opener; save() }
|
|
689
772
|
}
|
|
@@ -693,29 +776,33 @@ export function createTodayBrief(deps: TodayBriefDeps): TodayBrief {
|
|
|
693
776
|
if (!resolveSwitch().enabled) return view('')
|
|
694
777
|
return view(load().lastAskFingerprint)
|
|
695
778
|
},
|
|
696
|
-
gate() { return gate(load()) },
|
|
779
|
+
gate(opts) { return gate(load(), opts?.force === true, opts?.reconnect === true) },
|
|
697
780
|
askGated(pageInputs, reason) {
|
|
698
781
|
if (!resolveSwitch().enabled) return view('')
|
|
699
782
|
const fingerprint = todayBriefFingerprint(pageInputs)
|
|
700
783
|
remember(load(), fingerprint, todayBriefOpener(pageInputs))
|
|
701
784
|
return view(fingerprint, reason)
|
|
702
785
|
},
|
|
703
|
-
async ask(inputs, pageFingerprint = '') {
|
|
786
|
+
async ask(inputs, pageFingerprint = '', opts) {
|
|
704
787
|
if (!resolveSwitch().enabled) return view('')
|
|
788
|
+
const force = opts?.force === true
|
|
789
|
+
const reconnect = opts?.reconnect === true
|
|
705
790
|
const fingerprint = todayBriefFingerprint(inputs)
|
|
706
791
|
const f = load()
|
|
707
792
|
remember(f, fingerprint, todayBriefOpener(inputs))
|
|
708
793
|
const cache = todaysCache(f)
|
|
709
|
-
|
|
794
|
+
// A forced refresh writes again even when nothing changed: the person asked for a fresh summary.
|
|
795
|
+
if (!force && cache && cache.fingerprint === fingerprint) return view(fingerprint)
|
|
710
796
|
// Concurrent asks share one generation, whichever inputs started it.
|
|
711
797
|
if (inflight) { await inflight; return view(fingerprint, todaysCache(load())?.fingerprint === fingerprint ? undefined : 'interval') }
|
|
712
798
|
if (!hasBriefInputs(inputs)) return view(fingerprint, 'empty_inputs')
|
|
713
|
-
const closed = gate(f)
|
|
799
|
+
const closed = gate(f, force, reconnect)
|
|
714
800
|
if (closed) return view(fingerprint, closed)
|
|
801
|
+
const reconnectSkip = reconnectSkips(f, force, reconnect)
|
|
715
802
|
// The call holds the server's lease; a drain that began after the gate refuses here, before anything is spent.
|
|
716
803
|
let lease: { release(): void } | null = null
|
|
717
804
|
try { lease = deps.lease ? deps.lease() : null } catch { return view(fingerprint, 'maintenance') }
|
|
718
|
-
const run = generate(inputs, fingerprint, pageFingerprint, lease)
|
|
805
|
+
const run = generate(inputs, fingerprint, pageFingerprint, lease, force, reconnectSkip)
|
|
719
806
|
inflight = run
|
|
720
807
|
try { await run } finally { if (inflight === run) inflight = null }
|
|
721
808
|
const after = todaysCache(load())
|
|
@@ -723,9 +810,13 @@ export function createTodayBrief(deps: TodayBriefDeps): TodayBrief {
|
|
|
723
810
|
},
|
|
724
811
|
settings: settingsView,
|
|
725
812
|
setEnabled(enabled) {
|
|
726
|
-
|
|
727
|
-
durableAtomicWriteFileSync(deps.settingsFile, JSON.stringify({ schema: 1, enabled }) + '\n', { mode: 0o600 })
|
|
813
|
+
writeSettings({ enabled })
|
|
728
814
|
return settingsView()
|
|
729
815
|
},
|
|
816
|
+
cap: capView,
|
|
817
|
+
setDailyCap(value) {
|
|
818
|
+
writeSettings({ dailyCap: clampTodayBriefDailyCap(value) })
|
|
819
|
+
return capView()
|
|
820
|
+
},
|
|
730
821
|
}
|
|
731
822
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getOwnerName } from './profile.js'
|
|
2
2
|
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
3
|
-
import { getNeedsYouReader, needsYouCount, type NeedsYouBlock } from './learning-needs-you.js'
|
|
3
|
+
import { getNeedsYouReader, memoryReview, needsYouCount, type NeedsYouBlock } from './learning-needs-you.js'
|
|
4
4
|
|
|
5
5
|
export const READ_ONLY_MEMORY_WEB_OPS = new Set([
|
|
6
6
|
'status', 'learning.list', 'learning.review', 'learning.event', 'learning.status',
|
|
@@ -101,6 +101,8 @@ export async function dispatchMemoriesWebOp(
|
|
|
101
101
|
// when it cannot be read. Never `learning.to_review`: that is an object of counts, and
|
|
102
102
|
// the page rendered it as "To review (NaN)".
|
|
103
103
|
learningToReview: needsYouCount(needsYou),
|
|
104
|
+
// Every item that needs review past the shown cap, by the same predicate: the "of N" in Today's "Next 7 of N".
|
|
105
|
+
learningToReviewTotal: needsYou ? memoryReview.total(needsYou) : null,
|
|
104
106
|
learningTotal: learning.total ?? 0,
|
|
105
107
|
learningRewardEnabled: needsYou ? needsYou.reward_enabled === true : learning.reward_enabled === true,
|
|
106
108
|
...(needsYou ? { needs_you: needsYou } : {}),
|