@gotcos/glasses-server 6.63.1 → 6.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * When a Work task was created and when its line last changed (6.65.0, Control 0.5.259 "Newest created" and
3
+ * "Recent activity" orders). Read-only: nothing is ever written into tasks.md (canary C6).
4
+ *
5
+ * No task carries a creation time (0 of 293 on 2026-10-06), so the date is inferred, in this order:
6
+ * 1. createdOn from the source label: the first YYYY-MM-DD in it (241 of 293 live tasks carry one, usually the
7
+ * meeting date). `createdFrom: 'source'`.
8
+ * 2. Else the day the task's text first appeared in the git history of its tasks.md
9
+ * (`git log --reverse -S<text> -- tasks.md`, author date). `createdFrom: 'git'`. Each lookup is one git process,
10
+ * so they are cached on disk by (file, text hash). A lookup that times out or fails is never cached as "not in
11
+ * history"; it rests (TASK_DATE_LIMITS.lookupBackoffMs) and is tried again.
12
+ * 3. Else null.
13
+ * lineChangedAt is `git blame` of the line (author time), for committed lines only: an uncommitted line is null, never
14
+ * "now". tasks.md is auto-committed by the COS sync, so this is a coarse floor. Blame runs once per (HEAD, file mtime).
15
+ *
16
+ * Every dated read keeps ALL of its git work (rev-parse, blame, first-seen lookups) inside one deadline,
17
+ * TASK_DATE_LIMITS.deadlineMs (1.5 s, derived from the phone's 4 s request timeout below). Past it the read answers with
18
+ * what it has, and git work already started keeps running to fill the caches for a later read.
19
+ *
20
+ * Git runs only when a `.git` exists at or above the tasks.md folder. A public install keeps its tasks under
21
+ * ~/.cos-glasses/data, normally outside any repository, so no git process starts and the git-derived fields are null.
22
+ * If the home folder itself is a git repository, git does run: rev-parse on each dated read and blame once per HEAD or
23
+ * file change. Blame finds tasks.md untracked, so the git-derived fields stay null and no first-seen lookup runs.
24
+ */
25
+ import { createHash } from 'node:crypto'
26
+ import { execFile } from 'node:child_process'
27
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
28
+ import { basename, dirname, join } from 'node:path'
29
+ import { dataPath } from './data-dir.js'
30
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
31
+
32
+ export interface TaskDateFields {
33
+ /** 'YYYY-MM-DD' or null. */
34
+ createdOn: string | null
35
+ createdFrom: 'source' | 'git' | null
36
+ /** ISO time of the commit that last changed the task's line, or null (uncommitted, or no git). */
37
+ lineChangedAt: string | null
38
+ }
39
+ export const NO_TASK_DATES: Readonly<TaskDateFields> = Object.freeze({ createdOn: null, createdFrom: null, lineChangedAt: null })
40
+
41
+ /**
42
+ * The phone and the glasses give up on a request after 4 s. A dated board read keeps all of its git work (rev-parse,
43
+ * blame, first-seen lookups) inside half of that, less an allowance for the board read itself (the task bridge answers
44
+ * in about 0.1 s) and the answer: 4,000 / 2 - 500 = 1,500 ms.
45
+ */
46
+ const CLIENT_TIMEOUT_MS = 4_000
47
+ const BOARD_READ_ALLOWANCE_MS = 500
48
+
49
+ export const TASK_DATE_LIMITS = {
50
+ /** All git work for one request, overall. What does not finish keeps running and answers on a later call. */
51
+ deadlineMs: CLIENT_TIMEOUT_MS / 2 - BOARD_READ_ALLOWANCE_MS,
52
+ /** resolveTaskDatesWithin's grace past the deadline before it answers null dates for every row. */
53
+ answerSlackMs: 100,
54
+ /** One git process. It may outlive the request (it fills the caches); it is killed after this. */
55
+ gitTimeoutMs: 8_000,
56
+ /** A first-seen lookup that failed is not retried for this long; it is never cached as "not in history". */
57
+ lookupBackoffMs: 10 * 60_000,
58
+ /** A file whose rev-parse or blame timed out gets no git work for this long. */
59
+ fileBackoffMs: 60_000,
60
+ concurrency: 2,
61
+ cacheEntries: 5_000,
62
+ blameFiles: 32,
63
+ needleMin: 8,
64
+ needleMax: 200,
65
+ /** The leading characters of a task's text that must appear on its blamed line. */
66
+ guardChars: 40,
67
+ } as const
68
+ export const TASK_DATES_CACHE_FILE = dataPath('task-created-dates.json')
69
+
70
+ /** What a bridge row offers (task-store.ts BridgeTaskRow). */
71
+ export interface DateSourceRow { id: string; domain: string; line_number: number; source: string | null; description: string }
72
+ export type DateGitRunner = (args: readonly string[], cwd: string, timeoutMs: number) => Promise<string>
73
+
74
+ /** Homebrew's git first: `/usr/bin/git` without the developer tools opens an install dialog. */
75
+ const GIT_BIN = ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/usr/bin/git'].find(path => existsSync(path)) ?? '/usr/bin/git'
76
+ export const realDateGitRunner: DateGitRunner = (args, cwd, timeoutMs) => new Promise((resolve, reject) => {
77
+ execFile(GIT_BIN, [...args], { cwd, timeout: timeoutMs, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
78
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } }, (error, stdout) => error ? reject(error) : resolve(String(stdout)))
79
+ })
80
+
81
+ export function taskDateKey(domain: string, id: string): string { return `${domain}\0${id}` }
82
+
83
+ function isCalendarDay(day: string): boolean {
84
+ const at = new Date(`${day}T00:00:00Z`)
85
+ return !Number.isNaN(at.getTime()) && at.toISOString().slice(0, 10) === day
86
+ }
87
+
88
+ /** The first real YYYY-MM-DD in a source label. Link targets are not label text (a Work details token is base64). */
89
+ export function sourceDate(source: string | null | undefined): string | null {
90
+ if (!source) return null
91
+ const label = source.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
92
+ for (const match of label.matchAll(/20\d\d-\d\d-\d\d/g)) if (isCalendarDay(match[0])) return match[0]
93
+ return null
94
+ }
95
+
96
+ export interface BlameLine { content: string; at: string | null }
97
+ /** `git blame --line-porcelain` output to line number -> content and author time (null for an uncommitted line). */
98
+ export function parseBlamePorcelain(output: string): Map<number, BlameLine> {
99
+ const lines = new Map<number, BlameLine>()
100
+ let current: { sha: string; line: number; time: number | null } | null = null
101
+ for (const raw of output.split('\n')) {
102
+ if (raw.startsWith('\t')) {
103
+ if (current) {
104
+ const committed = !/^0+$/.test(current.sha) && current.time !== null && Number.isFinite(current.time)
105
+ lines.set(current.line, { content: raw.slice(1), at: committed ? new Date(current.time! * 1000).toISOString() : null })
106
+ }
107
+ current = null
108
+ continue
109
+ }
110
+ const head = /^([0-9a-f]{40}|[0-9a-f]{64}) \d+ (\d+)(?: \d+)?$/.exec(raw)
111
+ if (head) { current = { sha: head[1], line: Number(head[2]), time: null }; continue }
112
+ if (current && raw.startsWith('author-time ')) current.time = Number(raw.slice('author-time '.length))
113
+ }
114
+ return lines
115
+ }
116
+
117
+ /** The line still holds the task (the file can change between the board read and the blame). */
118
+ export function lineHolds(content: string, description: string): boolean {
119
+ const probe = description.trim().slice(0, TASK_DATE_LIMITS.guardChars)
120
+ return probe.length > 0 && content.includes(probe)
121
+ }
122
+
123
+ /** The text to find in history: the task's own words (not its checkbox, source or markers, which change later). */
124
+ export function pickaxeNeedle(description: string, content?: string): string | null {
125
+ const text = description.trim()
126
+ if (text.length < TASK_DATE_LIMITS.needleMin || text.includes('\0')) return null
127
+ const full = text.slice(0, TASK_DATE_LIMITS.needleMax)
128
+ if (!content || content.includes(full)) return full
129
+ const head = text.slice(0, TASK_DATE_LIMITS.guardChars)
130
+ return content.includes(head) ? head : full
131
+ }
132
+
133
+ /** The nearest folder at or above `dir` holding `.git` (a folder, or a pointer file), or null. */
134
+ export function findGitWorkTree(dir: string): string | null {
135
+ let at = dir
136
+ for (let depth = 0; depth < 64; depth++) {
137
+ if (existsSync(join(at, '.git'))) return at
138
+ const up = dirname(at)
139
+ if (up === at) return null
140
+ at = up
141
+ }
142
+ return null
143
+ }
144
+
145
+ interface FileState { file: string; dir: string; head: string; lines: Map<number, BlameLine> }
146
+ /** `d`: the day, or null when history has no such text yet; then `h` is the HEAD it was looked up at. */
147
+ interface CreatedEntry { d: string | null; h?: string }
148
+ interface PickaxeJob { key: string; needle: string; state: FileState }
149
+
150
+ /** execFile kills a git that outlives its timeout: a timeout says nothing about the history or the file. */
151
+ export function isGitTimeout(error: unknown): boolean {
152
+ const e = error as { killed?: unknown; signal?: unknown; code?: unknown } | null
153
+ return !!e && (e.killed === true || (typeof e.signal === 'string' && e.signal.length > 0) || e.code === 'ETIMEDOUT')
154
+ }
155
+
156
+ export interface TaskDateResolverOptions {
157
+ git?: DateGitRunner
158
+ cacheFile?: string
159
+ /** The overall deadline for one resolve (TASK_DATE_LIMITS.deadlineMs). */
160
+ deadlineMs?: number
161
+ now?: () => number
162
+ findRepo?: (dir: string) => string | null
163
+ }
164
+
165
+ export class TaskDateResolver {
166
+ private readonly git: DateGitRunner
167
+ private readonly cacheFile: string
168
+ private readonly deadlineMs: number
169
+ private readonly now: () => number
170
+ private readonly findRepo: (dir: string) => string | null
171
+ private blame = new Map<string, { key: string; lines: Promise<Map<number, BlameLine> | null> }>()
172
+ private created: Map<string, CreatedEntry> | null = null
173
+ private pending = new Map<string, Promise<void>>()
174
+ /** A first-seen lookup that failed (a timeout, a git error): not "absent", and not retried until this time. */
175
+ private lookupBackoff = new Map<string, number>()
176
+ /** A file whose rev-parse or blame timed out: no git for it until this time. */
177
+ private fileBackoff = new Map<string, number>()
178
+ private dirty = false
179
+ private flushTimer: ReturnType<typeof setTimeout> | null = null
180
+
181
+ constructor(options: TaskDateResolverOptions = {}) {
182
+ this.git = options.git ?? realDateGitRunner
183
+ this.cacheFile = options.cacheFile ?? TASK_DATES_CACHE_FILE
184
+ this.deadlineMs = options.deadlineMs ?? TASK_DATE_LIMITS.deadlineMs
185
+ this.now = options.now ?? Date.now
186
+ this.findRepo = options.findRepo ?? findGitWorkTree
187
+ }
188
+
189
+ /**
190
+ * Date fields for every row, keyed by taskDateKey(domain, id). Never throws; an unknown is null. Everything git does
191
+ * for this call (rev-parse, blame, first-seen lookups) shares ONE deadline: past it the call answers with what it
192
+ * has, source dates included, and git work already started keeps running to fill the caches for a later call.
193
+ */
194
+ async resolve(rows: readonly DateSourceRow[], root: string): Promise<Map<string, TaskDateFields>> {
195
+ const deadline = this.now() + this.deadlineMs
196
+ const out = new Map<string, TaskDateFields>()
197
+ const states = new Map<string, FileState | null>()
198
+ const reads = [...new Set(rows.map(row => row.domain))].map(domain =>
199
+ this.fileState(join(root, domain, 'tasks.md')).catch(() => null).then(state => { states.set(domain, state) }))
200
+ await this.until(Promise.all(reads), deadline)
201
+ const jobs = new Map<string, PickaxeJob>()
202
+ const waiting: Array<{ fields: TaskDateFields; key: string }> = []
203
+ for (const row of rows) {
204
+ const fields: TaskDateFields = { ...NO_TASK_DATES }
205
+ out.set(taskDateKey(row.domain, row.id), fields)
206
+ const fromSource = sourceDate(row.source)
207
+ if (fromSource) { fields.createdOn = fromSource; fields.createdFrom = 'source' }
208
+ const state = states.get(row.domain)
209
+ if (!state) continue
210
+ const line = state.lines.get(row.line_number)
211
+ if (line && lineHolds(line.content, row.description)) fields.lineChangedAt = line.at
212
+ if (fromSource) continue
213
+ const needle = pickaxeNeedle(row.description, line?.content)
214
+ if (!needle) continue
215
+ const key = createHash('sha256').update(state.file + '\0' + needle).digest('hex').slice(0, 32)
216
+ const known = this.lookup(key, state.head)
217
+ if (known === undefined) { jobs.set(key, { key, needle, state }); waiting.push({ fields, key }) }
218
+ else if (known) { fields.createdOn = known; fields.createdFrom = 'git' }
219
+ }
220
+ if (jobs.size) {
221
+ await this.runWithin([...jobs.values()], deadline)
222
+ for (const { fields, key } of waiting) {
223
+ const day = this.created?.get(key)?.d
224
+ if (day) { fields.createdOn = day; fields.createdFrom = 'git' }
225
+ }
226
+ }
227
+ return out
228
+ }
229
+
230
+ /** Write the first-seen cache now (it is otherwise written shortly after a lookup lands). */
231
+ flush(): void {
232
+ if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null }
233
+ if (!this.dirty || !this.created) return
234
+ this.dirty = false
235
+ try {
236
+ mkdirSync(dirname(this.cacheFile), { recursive: true, mode: 0o700 })
237
+ durableAtomicWriteFileSync(this.cacheFile, JSON.stringify({ version: 1, created: Object.fromEntries(this.created) }) + '\n', { mode: 0o600 })
238
+ } catch (e) { console.warn('[task-dates] cache not saved:', e instanceof Error ? e.message : e) }
239
+ }
240
+
241
+ /** Lookups still running in the background (tests). */
242
+ async settled(): Promise<void> {
243
+ while (this.pending.size) await Promise.allSettled([...this.pending.values()])
244
+ await Promise.allSettled([...this.blame.values()].map(entry => entry.lines))
245
+ }
246
+
247
+ /** Wait for `work` or the deadline, whichever comes first. */
248
+ private async until(work: Promise<unknown>, deadline: number): Promise<void> {
249
+ let timer: ReturnType<typeof setTimeout> | undefined
250
+ const late = new Promise<void>(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - this.now())) })
251
+ try { await Promise.race([work, late]) } finally { clearTimeout(timer) }
252
+ }
253
+
254
+ private async fileState(file: string): Promise<FileState | null> {
255
+ const dir = dirname(file)
256
+ if (!this.findRepo(dir)) return null
257
+ if ((this.fileBackoff.get(file) ?? 0) > this.now()) return null
258
+ let stat: ReturnType<typeof statSync>
259
+ try { stat = statSync(file) } catch { return null }
260
+ if (!stat.isFile()) return null
261
+ let head: string
262
+ try { head = (await this.git(['rev-parse', '--verify', '-q', 'HEAD'], dir, TASK_DATE_LIMITS.gitTimeoutMs)).trim() } catch (e) {
263
+ if (isGitTimeout(e)) this.fileBackoff.set(file, this.now() + TASK_DATE_LIMITS.fileBackoffMs)
264
+ return null
265
+ }
266
+ if (!/^[0-9a-f]{40,64}$/.test(head)) return null
267
+ const key = `${head}|${stat.mtimeMs}|${stat.size}`
268
+ let entry = this.blame.get(file)
269
+ if (!entry || entry.key !== key) {
270
+ // An untracked file fails blame: nothing about it comes from git, until HEAD or the file changes. A blame that
271
+ // timed out says nothing about the file, so it is forgotten (and the file rests) rather than kept as "untracked".
272
+ const blamed = { key, lines: this.git(['blame', '--line-porcelain', '--', basename(file)], dir, TASK_DATE_LIMITS.gitTimeoutMs)
273
+ .then(parseBlamePorcelain, (e: unknown) => {
274
+ if (isGitTimeout(e)) {
275
+ this.fileBackoff.set(file, this.now() + TASK_DATE_LIMITS.fileBackoffMs)
276
+ if (this.blame.get(file) === blamed) this.blame.delete(file)
277
+ }
278
+ return null
279
+ }) }
280
+ entry = blamed
281
+ this.blame.delete(file)
282
+ this.blame.set(file, entry)
283
+ while (this.blame.size > TASK_DATE_LIMITS.blameFiles) this.blame.delete(this.blame.keys().next().value!)
284
+ }
285
+ const lines = await entry.lines
286
+ return lines ? { file, dir, head, lines } : null
287
+ }
288
+
289
+ /** The cached day, null when history had no such text at this HEAD, or undefined when it must be looked up. */
290
+ private lookup(key: string, head: string): string | null | undefined {
291
+ const entry = this.cache().get(key)
292
+ if (entry?.d) return entry.d
293
+ if (entry && entry.h === head) return null
294
+ // A lookup that failed recently is neither known nor retried yet: the field stays null for now.
295
+ if ((this.lookupBackoff.get(key) ?? 0) > this.now()) return null
296
+ return undefined
297
+ }
298
+
299
+ /** Start lookups until the deadline, then answer with what landed. Lookups already started keep running. */
300
+ private async runWithin(jobs: PickaxeJob[], deadline: number): Promise<void> {
301
+ let next = 0
302
+ const worker = async () => {
303
+ while (next < jobs.length && this.now() < deadline) await this.pickaxe(jobs[next++])
304
+ }
305
+ await this.until(Promise.all(Array.from({ length: Math.min(TASK_DATE_LIMITS.concurrency, jobs.length) }, worker)), deadline)
306
+ }
307
+
308
+ private pickaxe(job: PickaxeJob): Promise<void> {
309
+ const running = this.pending.get(job.key)
310
+ if (running) return running
311
+ const work = this.git(['log', '--reverse', '--format=%ad', '--date=short', `-S${job.needle}`, '--', basename(job.state.file)],
312
+ job.state.dir, TASK_DATE_LIMITS.gitTimeoutMs)
313
+ .then(out => {
314
+ const day = out.split('\n').map(line => line.trim()).find(line => /^\d{4}-\d{2}-\d{2}$/.test(line)) ?? null
315
+ this.lookupBackoff.delete(job.key)
316
+ this.remember(job.key, day ? { d: day } : { d: null, h: job.state.head })
317
+ }, () => {
318
+ // A timeout or a git error is not "not in history": nothing is cached, and the lookup rests before a retry.
319
+ this.lookupBackoff.set(job.key, this.now() + TASK_DATE_LIMITS.lookupBackoffMs)
320
+ })
321
+ .finally(() => this.pending.delete(job.key))
322
+ this.pending.set(job.key, work)
323
+ return work
324
+ }
325
+
326
+ private cache(): Map<string, CreatedEntry> {
327
+ if (this.created) return this.created
328
+ this.created = new Map()
329
+ try {
330
+ const parsed = JSON.parse(readFileSync(this.cacheFile, 'utf8')) as { version?: unknown; created?: Record<string, unknown> }
331
+ if (parsed?.version === 1 && parsed.created && typeof parsed.created === 'object') {
332
+ for (const [key, raw] of Object.entries(parsed.created)) {
333
+ const entry = raw as Partial<CreatedEntry>
334
+ if (typeof entry?.d === 'string' && isCalendarDay(entry.d)) this.created.set(key, { d: entry.d })
335
+ else if (entry?.d === null && typeof entry.h === 'string') this.created.set(key, { d: null, h: entry.h })
336
+ }
337
+ }
338
+ } catch { /* missing or unreadable: start empty */ }
339
+ return this.created
340
+ }
341
+
342
+ private remember(key: string, entry: CreatedEntry): void {
343
+ const cache = this.cache()
344
+ cache.delete(key)
345
+ cache.set(key, entry)
346
+ while (cache.size > TASK_DATE_LIMITS.cacheEntries) cache.delete(cache.keys().next().value!)
347
+ this.dirty = true
348
+ if (!this.flushTimer) {
349
+ this.flushTimer = setTimeout(() => this.flush(), 250)
350
+ this.flushTimer.unref?.()
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * The resolver, bounded from outside as well: if it has not answered by the deadline (plus a moment for the answer
357
+ * itself), every row gets null dates. A dated board read is never held past the deadline by git.
358
+ */
359
+ export async function resolveTaskDatesWithin(resolver: Pick<TaskDateResolver, 'resolve'>, rows: readonly DateSourceRow[], root: string,
360
+ deadlineMs: number = TASK_DATE_LIMITS.deadlineMs): Promise<Map<string, TaskDateFields>> {
361
+ let timer: ReturnType<typeof setTimeout> | undefined
362
+ const late = new Promise<Map<string, TaskDateFields>>(resolve => {
363
+ timer = setTimeout(() => resolve(new Map()), deadlineMs + TASK_DATE_LIMITS.answerSlackMs)
364
+ })
365
+ try { return await Promise.race([resolver.resolve(rows, root), late]) } finally { clearTimeout(timer) }
366
+ }
367
+
368
+ let shared: TaskDateResolver | null = null
369
+ export function sharedTaskDateResolver(): TaskDateResolver { return shared ??= new TaskDateResolver() }
@@ -10,6 +10,8 @@ import {
10
10
  } from './morning-brief-config.js'
11
11
  import { localClock, shiftDay, taskInstant } from './morning-brief-schedule.js'
12
12
  import { callTaskBridge, taskBridgeAvailable, taskOperationsRoot, taskBridgeUnavailableMessage } from './task-bridge.js'
13
+ import { NO_TASK_DATES, TASK_DATE_LIMITS, resolveTaskDatesWithin, sharedTaskDateResolver, taskDateKey, type TaskDateFields,
14
+ type TaskDateResolver } from './task-dates.js'
13
15
  import { taskDomainNames, isSafeDomainName } from './domains.js'
14
16
  import { isClientJobId } from './query-job-types.js'
15
17
  import { DEFAULT_MODEL, isClaudeModel } from '../../shared/model-preference.js'
@@ -469,7 +471,7 @@ export function projectRow(
469
471
  }
470
472
  }
471
473
 
472
- export async function listBoard(columnFilter?: string, nowMs = Date.now()): Promise<TaskBoardRow[]> {
474
+ async function boardPairs(columnFilter: string | undefined, nowMs: number): Promise<Array<{ source: BridgeTaskRow; row: TaskBoardRow }>> {
473
475
  if (!taskBridgeAvailable()) {
474
476
  throw new TaskRunError(503, 'cos_pipeline_not_configured', taskBridgeUnavailableMessage())
475
477
  }
@@ -479,8 +481,33 @@ export async function listBoard(columnFilter?: string, nowMs = Date.now()): Prom
479
481
  Promise.resolve(loadTaskLedger(taskStorePaths())),
480
482
  ])
481
483
  return rows
482
- .map(row => projectRow(row, ledger, clock.day, nowMs, config.timezone, config.taskCatchUpMinutes))
483
- .filter((row): row is TaskBoardRow => !!row && (!columnFilter || row.column === columnFilter))
484
+ .map(source => ({ source, row: projectRow(source, ledger, clock.day, nowMs, config.timezone, config.taskCatchUpMinutes) }))
485
+ .filter((pair): pair is { source: BridgeTaskRow; row: TaskBoardRow } => !!pair.row && (!columnFilter || pair.row.column === columnFilter))
486
+ }
487
+
488
+ export async function listBoard(columnFilter?: string, nowMs = Date.now()): Promise<TaskBoardRow[]> {
489
+ return (await boardPairs(columnFilter, nowMs)).map(pair => pair.row)
490
+ }
491
+
492
+ /**
493
+ * GET /api/tasks and GET /api/work-board rows (6.65.0): the board plus `createdOn`, `createdFrom` and `lineChangedAt`
494
+ * (lib/task-dates.ts). Every caller of those two routes pays for the dated read, the glasses' and the phone's /api/tasks
495
+ * reads included: its git work is bounded by TASK_DATE_LIMITS.deadlineMs (1.5 s, under the phone's 4 s timeout), past
496
+ * which the dates come back null. The shared Work board reader (the glasses' activity, open-work and request glances)
497
+ * and Jev advice use listBoard, which does no git work.
498
+ * Additive: every existing field is unchanged, and older Controls ignore the new ones. The dates come from the source
499
+ * label and from git, read-only, for both the configured COS bridge and the packaged task runtime; a date that cannot
500
+ * be known is null, and a failure to date never fails the board.
501
+ */
502
+ export async function listBoardWithDates(columnFilter?: string, nowMs = Date.now(),
503
+ resolver: Pick<TaskDateResolver, 'resolve'> = sharedTaskDateResolver(),
504
+ deadlineMs: number = TASK_DATE_LIMITS.deadlineMs): Promise<Array<TaskBoardRow & TaskDateFields>> {
505
+ const pairs = await boardPairs(columnFilter, nowMs)
506
+ let dates = new Map<string, TaskDateFields>()
507
+ try { dates = await resolveTaskDatesWithin(resolver, pairs.map(pair => pair.source), taskOperationsRoot(), deadlineMs) } catch (e) {
508
+ console.warn('[tasks] dates unavailable:', e instanceof Error ? e.message : e)
509
+ }
510
+ return pairs.map(({ row }) => ({ ...row, ...(dates.get(taskDateKey(row.domain, row.id)) ?? NO_TASK_DATES) }))
484
511
  }
485
512
 
486
513
  export function workBadgeCount(rows: readonly TaskBoardRow[]): number {
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Work search by meaning (6.65.0, Control 0.5.259).
3
+ *
4
+ * Control searches the board's cards by words on its own, instantly. After a pause it asks this for the card the user
5
+ * MEANS: one Jev Choice over the board's task texts, with `none` as an option. Measured 2026-10-06 on 105 real Quilt
6
+ * tasks and 10 queries: the right card first 10 of 10 times in 0.2 s (keyword search alone found 7 of 10).
7
+ *
8
+ * The candidates are the board's own rows (the same listBoard read /api/tasks serves), never client text: the client
9
+ * names a scope, a domain, or exact ids. A Choice holds at most 254 options plus `none`, so a larger set is refused as
10
+ * `too_many_candidates` rather than truncated (a truncated search would silently miss cards).
11
+ *
12
+ * Cost controls of its own, separate from Work intake and session advice (COS_JEV_DAILY_TOKENS): a daily input-token
13
+ * cap (COS_JEV_SEARCH_DAILY_TOKENS, default 1,000,000, about 200 searches at the ~5k input tokens measured per search:
14
+ * 64.5k over 13 live calls), its own usage ledger, its own breaker (3 failures, then 1 hour), and a switch
15
+ * (COS_JEV_SEARCH=0 turns it off). Key resolution and the request timeout are jev.ts's. A search that cannot run
16
+ * answers HTTP 200 with `available: false` and a reason, so Control keeps its word search and says one line about why.
17
+ */
18
+ import { createHash } from 'node:crypto'
19
+ import { dataPath } from './data-dir.js'
20
+ import { estimateJevTokens, JevClient, JevError } from './jev.js'
21
+ import { boundForRedaction, redactSecretText } from './activity-preview.js'
22
+ import { redactSecrets } from './lens-gist-engines.js'
23
+
24
+ export const WORK_SEARCH_LIMITS = {
25
+ queryMin: 2, queryMax: 200,
26
+ /** A Jev Choice holds 255 options; one is `none`. */
27
+ maxCandidates: 254,
28
+ optionChars: 300,
29
+ /** How much of a task's text the redaction reads: four times what Jev gets, so no pattern ever scans a long input. */
30
+ optionScanChars: 1_200,
31
+ minP: 0.02, maxResults: 20,
32
+ cacheMs: 10 * 60_000, cacheEntries: 200,
33
+ defaultDailyTokens: 1_000_000,
34
+ } as const
35
+ export const WORK_SEARCH_USAGE_FILE = dataPath('jev-search-usage.json')
36
+ export const WORK_SEARCH_SCOPES = ['all', 'attention', 'progress', 'completed'] as const
37
+ export type WorkSearchScope = typeof WORK_SEARCH_SCOPES[number]
38
+
39
+ export const WORK_SEARCH_INSTRUCTIONS = 'The user typed `query` into the search box of their task board. Which task are they trying '
40
+ + 'to find? Pick the task they mean even when it uses different words, or none if no task fits.'
41
+ export const WORK_SEARCH_NONE = 'None of these tasks is what the user is looking for.'
42
+
43
+ export type WorkSearchReason = 'jev_not_configured' | 'jev_key_rejected' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_request_rejected'
44
+ | 'jev_unavailable' | 'search_off' | 'too_many_candidates'
45
+ export const WORK_SEARCH_MESSAGES: Record<WorkSearchReason, string> = {
46
+ jev_not_configured: 'Meaning search needs a TypeSafe key. Add one in Settings.',
47
+ jev_key_rejected: 'TypeSafe did not accept the saved key. Check it in Settings. Word search still works.',
48
+ jev_cap_reached: 'Meaning search has used today’s budget. It resets at midnight UTC. Word search still works.',
49
+ jev_breaker_open: 'Meaning search is paused for an hour after repeated failures. Word search still works.',
50
+ jev_request_rejected: 'Jev refused this search request. Word search still works.',
51
+ jev_unavailable: 'Meaning search got no answer from Jev. Word search still works.',
52
+ search_off: 'Meaning search is turned off on this server.',
53
+ too_many_candidates: `Meaning search covers up to ${WORK_SEARCH_LIMITS.maxCandidates} cards. Pick a domain to search this view.`,
54
+ }
55
+
56
+ export interface WorkSearchHit { id: string; p: number }
57
+ export type WorkSearchResult =
58
+ | { available: true; results: WorkSearchHit[]; none: number | null; cached: boolean; tokens: number }
59
+ | { available: false; reason: WorkSearchReason; message: string }
60
+
61
+ export function unavailable(reason: WorkSearchReason): Extract<WorkSearchResult, { available: false }> {
62
+ return { available: false, reason, message: WORK_SEARCH_MESSAGES[reason] }
63
+ }
64
+
65
+ /** The daily input-token cap for search alone. The ledger day is UTC, so the budget resets at midnight UTC. */
66
+ export function workSearchDailyCap(): number {
67
+ const raw = Number(process.env.COS_JEV_SEARCH_DAILY_TOKENS)
68
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : WORK_SEARCH_LIMITS.defaultDailyTokens
69
+ }
70
+
71
+ /**
72
+ * On unless COS_JEV_SEARCH says off (0, false, no, off). The value comes from the server's environment, which is fixed
73
+ * when the server starts (the LaunchAgent plist, or ~/.cos-glasses/.env read at boot), so changing it takes a restart.
74
+ * The cap, COS_JEV_SEARCH_DAILY_TOKENS, is the same.
75
+ */
76
+ export function workSearchEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
77
+ return !['0', 'false', 'no', 'off'].includes((env.COS_JEV_SEARCH ?? '').trim().toLowerCase())
78
+ }
79
+
80
+ /** Search's own Jev client: its own ledger file, cap and breaker. jev.ts supplies the key and the timeout. */
81
+ export function createWorkSearchJevClient(fetchImpl: typeof fetch = fetch, now: () => Date = () => new Date(),
82
+ usageFile = WORK_SEARCH_USAGE_FILE): JevClient {
83
+ return new JevClient(fetchImpl, now, usageFile, workSearchDailyCap)
84
+ }
85
+
86
+ let sharedSearchClient: JevClient | null = null
87
+ /** One search client per process: it owns search's breaker. Saving a new key resets it (routes/jev.ts). */
88
+ export function sharedWorkSearchJevClient(): JevClient { return sharedSearchClient ??= createWorkSearchJevClient() }
89
+
90
+ const WORK_DETAILS_RE = /\[Work details\]\([^)]*\)/gi
91
+ const MARKDOWN_LINK_RE = /\[([^\]]*)\]\([^)\s]*\)/g
92
+ // Bounded scheme, as in activity-preview.ts: an unbounded one is retried from every position of a long run.
93
+ const SCHEME_URL_RE = /\b(?:[a-z][a-z0-9+.-]{0,31}:\/\/|www\.)\S+/gi
94
+ const EMAIL_RE = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}\b/g
95
+ // A host with a path (docs.google.com/document/d/…). A bare domain stays: it is usually a brand name people search for.
96
+ const PATH_URL_RE = /\b(?:[a-z0-9-]{1,63}\.){1,8}[a-z]{2,24}\/\S*/gi
97
+ const LONG_RUN_RE = /[A-Za-z0-9_-]{24,}/g
98
+
99
+ /**
100
+ * What Jev reads for one card (6.65.0 QA, S-W6): the task text with `[Work details](…)` links dropped, other markdown
101
+ * links reduced to their label, credentials masked by the server's secret redaction (redactSecretText, then
102
+ * redactSecrets), and URLs, email addresses and opaque ids (24 or more letters, digits, `-` or `_` with both a letter and
103
+ * a digit, the shape of a key or a document id) removed. They add nothing to finding a card, and TypeSafe is a third
104
+ * party. One line, at most 300 characters.
105
+ */
106
+ export function searchOptionText(text: string): string {
107
+ let out = boundForRedaction(text, WORK_SEARCH_LIMITS.optionScanChars).head.replace(WORK_DETAILS_RE, ' ')
108
+ out = redactSecrets(redactSecretText(out))
109
+ out = out.replace(MARKDOWN_LINK_RE, '$1').replace(SCHEME_URL_RE, ' ').replace(EMAIL_RE, ' ').replace(PATH_URL_RE, ' ')
110
+ out = out.replace(LONG_RUN_RE, run => /\d/.test(run) && /[A-Za-z]/.test(run) ? ' ' : run)
111
+ return out.replace(/\s+/g, ' ').trim().slice(0, WORK_SEARCH_LIMITS.optionChars).trim()
112
+ }
113
+
114
+ /**
115
+ * API POSTs that only read (6.65.0 QA). index.ts lets them past the global mutation lease. POST /work/search reads the
116
+ * board and asks Jev one question; it writes nothing but its own token ledger (one atomic write). Under the lease, a
117
+ * drain for an update or restart would wait up to 20 s (the Jev timeout) for a search, and a drain would answer every
118
+ * search 503. Outside it, a restart mid-search loses at most that search's token count.
119
+ */
120
+ export function isReadOnlyApiPost(method: string, path: string): boolean {
121
+ return method === 'POST' && path === '/work/search'
122
+ }
123
+
124
+ /** A query's length as people count it: code points, not UTF-16 units, so 150 emoji are 150 (Control counts characters). */
125
+ export function queryLength(query: string): number { return [...query].length }
126
+
127
+ export interface SearchableRow { id: string; domain: string; text: string; checked: boolean; workIdentity?: string }
128
+ export interface CandidateRequest { domain?: string; scope?: WorkSearchScope; ids?: string[] }
129
+
130
+ /**
131
+ * The cards a search covers, in board order. Exact ids win: Control sends them for views only it can compute (Needs
132
+ * attention, In progress). Otherwise a domain board covers that domain's cards; All work covers open cards; Completed
133
+ * covers completed ones. Needs attention and In progress without ids cover open cards. Rows without searchable text
134
+ * are left out. Returns null when there are more than 254.
135
+ */
136
+ export function selectCandidates<Row extends SearchableRow>(rows: readonly Row[], request: CandidateRequest): Row[] | null {
137
+ const inDomain = (row: Row) => !request.domain || row.domain === request.domain
138
+ let picked: Row[]
139
+ if (request.ids) {
140
+ const wanted = new Set(request.ids)
141
+ picked = rows.filter(row => wanted.has(row.id) || (!!row.workIdentity && wanted.has(row.workIdentity)))
142
+ } else if (request.scope === 'completed') {
143
+ picked = rows.filter(row => row.checked && inDomain(row))
144
+ } else if (request.scope === 'attention' || request.scope === 'progress') {
145
+ picked = rows.filter(row => !row.checked && inDomain(row))
146
+ } else if (request.domain) {
147
+ picked = rows.filter(row => row.domain === request.domain)
148
+ } else {
149
+ picked = rows.filter(row => !row.checked)
150
+ }
151
+ picked = picked.filter(row => searchOptionText(row.text).length > 0)
152
+ return picked.length > WORK_SEARCH_LIMITS.maxCandidates ? null : picked
153
+ }
154
+
155
+ /** Jev's probabilities as hits: offered cards only, p at least 0.02, most likely first, at most 20. */
156
+ export function rankHits(probabilities: Record<string, unknown> | undefined, ids: readonly string[]): { results: WorkSearchHit[]; none: number | null } {
157
+ if (!probabilities || typeof probabilities !== 'object') throw new JevError('jev_bad_answer', 'Jev returned no probabilities')
158
+ const results: WorkSearchHit[] = []
159
+ let none: number | null = null
160
+ for (const [key, raw] of Object.entries(probabilities)) {
161
+ const p = typeof raw === 'number' && Number.isFinite(raw) ? raw : Number.NaN
162
+ if (Number.isNaN(p) || p < 0 || p > 1) throw new JevError('jev_bad_answer', 'Jev answered with a probability outside 0 to 1')
163
+ if (key === 'none') { none = round(p); continue }
164
+ const index = /^t(0|[1-9]\d*)$/.test(key) ? Number(key.slice(1)) : -1
165
+ if (index < 0 || index >= ids.length) throw new JevError('jev_bad_answer', 'Jev chose a card that was not offered')
166
+ if (p >= WORK_SEARCH_LIMITS.minP) results.push({ id: ids[index], p: round(p) })
167
+ }
168
+ results.sort((a, b) => b.p - a.p)
169
+ return { results: results.slice(0, WORK_SEARCH_LIMITS.maxResults), none }
170
+ }
171
+ const round = (n: number) => Math.round(n * 10_000) / 10_000
172
+
173
+ /** Case, spacing and Unicode form do not change what was asked. */
174
+ export function normalizeQuery(query: string): string {
175
+ return query.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
176
+ }
177
+
178
+ const PASSED_THROUGH = new Set<string>(['jev_not_configured', 'jev_key_rejected', 'jev_cap_reached', 'jev_breaker_open', 'jev_request_rejected'])
179
+
180
+ export class WorkSearcher {
181
+ private cache = new Map<string, { at: number; results: WorkSearchHit[]; none: number | null }>()
182
+ private inflight = new Map<string, Promise<WorkSearchResult>>()
183
+ constructor(private readonly jev: JevClient, private readonly now: () => number = () => Date.now()) {}
184
+
185
+ /** One Jev Choice over `candidates` (already selected, at most 254). Never throws for a Jev failure. */
186
+ async search(query: string, candidates: readonly SearchableRow[]): Promise<WorkSearchResult> {
187
+ if (candidates.length > WORK_SEARCH_LIMITS.maxCandidates) return unavailable('too_many_candidates')
188
+ if (!candidates.length) return { available: true, results: [], none: 1, cached: false, tokens: 0 }
189
+ const texts = candidates.map(row => searchOptionText(row.text))
190
+ const set = createHash('sha256').update(JSON.stringify(candidates.map((row, i) => [row.id, texts[i]]))).digest('hex')
191
+ const key = createHash('sha256').update(normalizeQuery(query) + '\0' + set).digest('hex')
192
+ const hit = this.cache.get(key)
193
+ if (hit && this.now() - hit.at < WORK_SEARCH_LIMITS.cacheMs) {
194
+ return { available: true, results: hit.results, none: hit.none, cached: true, tokens: 0 }
195
+ }
196
+ if (hit) this.cache.delete(key)
197
+ // Typing can send the same search twice before the first answers; the second waits for the first.
198
+ const running = this.inflight.get(key)
199
+ if (running) return running
200
+ const work = this.ask(query, candidates.map(row => row.id), texts, key)
201
+ this.inflight.set(key, work)
202
+ try { return await work } finally { this.inflight.delete(key) }
203
+ }
204
+
205
+ private async ask(query: string, ids: string[], texts: string[], key: string): Promise<WorkSearchResult> {
206
+ const state = { query: query.trim() }
207
+ const criteria: Record<string, string> = Object.fromEntries(texts.map((text, i) => [`t${i}`, text]))
208
+ criteria.none = WORK_SEARCH_NONE
209
+ const questions = { c: { type: 'choice', instructions: WORK_SEARCH_INSTRUCTIONS, criteria } }
210
+ try {
211
+ const reply = await this.jev.ask(state, questions, estimateJevTokens({ state, questions }))
212
+ const { results, none } = rankHits(reply.answers.c?.probabilities, ids)
213
+ if (this.cache.size >= WORK_SEARCH_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
214
+ this.cache.set(key, { at: this.now(), results, none })
215
+ return { available: true, results, none, cached: false, tokens: reply.inputTokens }
216
+ } catch (e) {
217
+ if (!(e instanceof JevError)) throw e
218
+ // Each says what happened. A 400, 413 or 422 is jev_request_rejected: the request itself was refused, which does
219
+ // not count toward the breaker (jev.ts), because the same cards would be refused again and an outage pause would
220
+ // only hide search for every other board. Unreachable, 5xx and a malformed answer are jev_unavailable.
221
+ if (PASSED_THROUGH.has(e.code)) return unavailable(e.code as WorkSearchReason)
222
+ console.warn('[work-search] Jev failed:', e.code)
223
+ return unavailable('jev_unavailable')
224
+ }
225
+ }
226
+ }