@gotcos/glasses-server 6.64.0 → 6.66.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.
- package/.env.example +5 -0
- package/CHANGELOG.md +28 -0
- package/README.md +32 -6
- package/bin/cli.cjs +20 -29
- package/bin/whisper-runtime.cjs +17 -0
- package/bin/whisper-runtime.d.cts +1 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +11 -0
- package/package.json +5 -2
- package/server/index.ts +7 -0
- package/server/lib/dictation-clean-models.ts +3 -3
- package/server/lib/dictation-clean.ts +1 -1
- package/server/lib/health-static-probes.ts +4 -2
- package/server/lib/jev.ts +5 -3
- package/server/lib/task-dates.ts +369 -0
- package/server/lib/task-store.ts +30 -3
- package/server/lib/whisper-local.ts +4 -11
- package/server/lib/whisper-preview.ts +2 -2
- package/server/lib/work-search.ts +226 -0
- package/server/routes/jev.ts +6 -1
- package/server/routes/prompt-drafts.ts +1 -1
- package/server/routes/tasks.ts +3 -1
- package/server/routes/work-board.ts +7 -2
- package/server/routes/work-search.ts +72 -0
|
@@ -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() }
|
package/server/lib/task-store.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
483
|
-
.filter((
|
|
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 {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveWhisperBinary } from '../../bin/whisper-runtime.cjs'
|
|
1
2
|
// Local Whisper transcription via whisper-server (persistent) or whisper-cli (fallback)
|
|
2
3
|
// Eliminates 1-3s OpenAI API round-trip by running inference on M3 Ultra locally.
|
|
3
4
|
//
|
|
@@ -130,17 +131,9 @@ interface WhisperJsonResponse {
|
|
|
130
131
|
text?: unknown
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
function resolveWhisperBin(name: string): string {
|
|
137
|
-
for (const prefix of ['/opt/homebrew/bin', '/usr/local/bin']) {
|
|
138
|
-
if (existsSync(`${prefix}/${name}`)) return `${prefix}/${name}`
|
|
139
|
-
}
|
|
140
|
-
return `/opt/homebrew/bin/${name}`
|
|
141
|
-
}
|
|
142
|
-
const WHISPER_CLI = resolveWhisperBin('whisper-cli')
|
|
143
|
-
const WHISPER_SERVER = resolveWhisperBin('whisper-server')
|
|
134
|
+
// Same resolution as setup and preview; a broken explicit override never falls through to Homebrew.
|
|
135
|
+
const WHISPER_CLI = resolveWhisperBinary('whisper-cli') ?? '/__cos_whisper_unavailable__/whisper-cli'
|
|
136
|
+
const WHISPER_SERVER = resolveWhisperBinary('whisper-server') ?? '/__cos_whisper_unavailable__/whisper-server'
|
|
144
137
|
const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
|
|
145
138
|
export const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
|
|
146
139
|
export const WHISPER_LARGE_V3_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3.bin')
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveWhisperBinary } from '../../bin/whisper-runtime.cjs'
|
|
1
2
|
// Adaptive provisional transcription:
|
|
2
3
|
// Balanced -> isolated Small.en cosmetic preview + Turbo live commit
|
|
3
4
|
// Max -> isolated Turbo cosmetic preview + Large-v3 live commit
|
|
@@ -55,8 +56,7 @@ export const WHISPER_SMALL_EN_MODEL_PATH = join(MODEL_DIR, 'ggml-small.en.bin')
|
|
|
55
56
|
const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
|
|
56
57
|
const VAD_MODEL_PATH = join(MODEL_DIR, 'ggml-silero-v5.1.2.bin')
|
|
57
58
|
const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
|
|
58
|
-
const WHISPER_SERVER =
|
|
59
|
-
.find(existsSync) ?? '/opt/homebrew/bin/whisper-server'
|
|
59
|
+
const WHISPER_SERVER = resolveWhisperBinary('whisper-server') ?? '/__cos_whisper_unavailable__/whisper-server'
|
|
60
60
|
const PREVIEW_PORT = 8177
|
|
61
61
|
const PREVIEW_URL = `http://127.0.0.1:${PREVIEW_PORT}`
|
|
62
62
|
const PROCESS_PROBE_TIMEOUT_MS = 2_000
|