@gotcos/glasses-server 6.99.33 → 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.
Files changed (76) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +417 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +40 -1
  56. package/server/web-control/app.js +1309 -56
  57. package/server/web-control/control.css +25 -0
  58. package/server/web-control/index.html +7 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +42 -3
  61. package/server/web-control/panes/cos-chat.js +267 -15
  62. package/server/web-control/panes/meetings.css +18 -2
  63. package/server/web-control/panes/meetings.js +24 -8
  64. package/server/web-control/panes/memories.css +17 -2
  65. package/server/web-control/panes/memories.js +127 -42
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.js +28 -8
  69. package/server/web-control/panes/reader-media.js +409 -23
  70. package/server/web-control/panes/sessions.css +32 -3
  71. package/server/web-control/panes/settings.js +96 -5
  72. package/server/web-control/panes/today-actions.css +11 -0
  73. package/server/web-control/panes/today-actions.js +45 -12
  74. package/server/web-control/panes/work.css +10 -0
  75. package/server/web-control/panes/work.js +10 -2
  76. package/server/web-control/shell.js +17 -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 60 starts a local day. A start is
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
- export const TODAY_BRIEF_MODEL = 'haiku'
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
- export const TODAY_BRIEF_DAILY_CAP = 60
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
- gate(): TodayBriefReason | null
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
- const dailyCap = deps.dailyCap ?? TODAY_BRIEF_DAILY_CAP
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
- const gate = (f: BriefFile): TodayBriefReason | null => {
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
- if (f.lastStartAt && within(now() - f.lastStartAt, minInterval)) return 'interval'
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
- if (cache && cache.fingerprint === fingerprint) return view(fingerprint)
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
- securePrivateDirectory(dirname(deps.settingsFile))
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 } : {}),