@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,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
|
+
}
|
package/server/routes/jev.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { SessionRecommender, parseCandidates } from '../lib/session-recommendati
|
|
|
12
12
|
import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
|
|
13
13
|
import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
|
|
14
14
|
import { listBoard } from '../lib/task-store.js'
|
|
15
|
+
import { sharedWorkSearchJevClient } from '../lib/work-search.js'
|
|
15
16
|
import { isSafeDomainName } from '../lib/domains.js'
|
|
16
17
|
|
|
17
18
|
/** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
|
|
@@ -30,6 +31,8 @@ export interface JevRouteDependencies {
|
|
|
30
31
|
/** 6.58.0: a session's replies since a time (or its newest reply) from this server's transcripts; null when the
|
|
31
32
|
* session is not on this Mac. */
|
|
32
33
|
replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
|
|
34
|
+
/** 6.65.0: Work search keeps its own client and breaker; a newly saved key clears that breaker too. */
|
|
35
|
+
searchJev: Pick<JevClient, 'resetForNewKey'>
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
|
|
@@ -40,7 +43,8 @@ function findTask<T extends { id: string; workIdentity?: string }>(rows: T[], id
|
|
|
40
43
|
export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
|
|
41
44
|
const jev = overrides.jev ?? sharedJevClient()
|
|
42
45
|
const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey,
|
|
43
|
-
completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
|
|
46
|
+
completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
|
|
47
|
+
searchJev: sharedWorkSearchJevClient(), ...overrides }
|
|
44
48
|
const router = Router()
|
|
45
49
|
router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
46
50
|
|
|
@@ -61,6 +65,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
61
65
|
return res.status(500).json({ error: { code: 'jev_key_not_saved', message: 'The key was valid but could not be saved. Check the server data folder.' } })
|
|
62
66
|
}
|
|
63
67
|
deps.jev.resetForNewKey()
|
|
68
|
+
deps.searchJev.resetForNewKey()
|
|
64
69
|
return res.json({ ok: true, ...deps.jev.status() })
|
|
65
70
|
})
|
|
66
71
|
|
|
@@ -136,7 +136,7 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
|
|
|
136
136
|
console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
|
|
137
137
|
}
|
|
138
138
|
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
|
|
139
|
-
const model = opts.model ?? '
|
|
139
|
+
const model = opts.model ?? 'haiku'
|
|
140
140
|
const autoCleanBreaker = autoCleanBreakers[model]
|
|
141
141
|
if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
|
|
142
142
|
const startedAt = Date.now()
|
package/server/routes/tasks.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
captureTask,
|
|
11
11
|
checkTask,
|
|
12
12
|
listBoard,
|
|
13
|
+
listBoardWithDates,
|
|
13
14
|
listProjectedTaskRuns,
|
|
14
15
|
loadDispatchCap,
|
|
15
16
|
moveTask,
|
|
@@ -175,7 +176,8 @@ tasksRouter.put('/domains', (req, res) => {
|
|
|
175
176
|
tasksRouter.get('/tasks', async (req, res) => {
|
|
176
177
|
try {
|
|
177
178
|
const column = typeof req.query.column === 'string' ? req.query.column : undefined
|
|
178
|
-
|
|
179
|
+
// 6.65.0: rows carry createdOn, createdFrom and lineChangedAt (null when unknown), as GET /api/work-board rows do.
|
|
180
|
+
const rows = await listBoardWithDates(column)
|
|
179
181
|
res.json({ tasks: rows, workBadge: workBadgeCount(rows), gate: tasksGate() })
|
|
180
182
|
} catch (error) {
|
|
181
183
|
sendError(res, error)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
2
|
import { readWorkJournal, projectWorkActivity, projectOpenWork, savedDestinationFor, controlSnapshotRevision,
|
|
3
3
|
type WorkActivityCapabilities, type WorkActivityResult } from '../lib/work-activity.js'
|
|
4
|
-
import { listBoard, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, editWorkTask, TaskRunError, TaskBridgeError,
|
|
4
|
+
import { listBoard, listBoardWithDates, workBoardCapabilities, setTaskWorkStage, linkTaskMeeting, editWorkTask, TaskRunError, TaskBridgeError,
|
|
5
5
|
WORK_STAGES, type WorkStage, type WorkMeetingRef, type TaskBoardRow } from '../lib/task-store.js'
|
|
6
6
|
import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
7
7
|
import type { MeetingDescriptor } from '../lib/work-review-store.js'
|
|
@@ -13,6 +13,10 @@ export interface WorkBoardDependencies {
|
|
|
13
13
|
/** The native journal reader. It takes no request-derived argument: there is no request-supplied path. */
|
|
14
14
|
journal: () => ReturnType<typeof readWorkJournal>
|
|
15
15
|
list: typeof listBoard
|
|
16
|
+
/** 6.65.0: the rows GET /api/work-board serves, with createdOn, createdFrom and lineChangedAt (task-store.ts
|
|
17
|
+
* listBoardWithDates). COS Control reads this route for its Work board and reads /api/tasks only from an older
|
|
18
|
+
* server, so the dates must be here too. Defaults to the dated read unless a caller supplies its own `list`. */
|
|
19
|
+
listDated?: () => Promise<TaskBoardRow[]>
|
|
16
20
|
capabilities: typeof workBoardCapabilities
|
|
17
21
|
stage: typeof setTaskWorkStage
|
|
18
22
|
link: typeof linkTaskMeeting
|
|
@@ -33,6 +37,7 @@ export const WORK_GLANCE_BOARD_AGE_MS = 10_000
|
|
|
33
37
|
/** Auth is supplied by the parent /api mount. No provider execution occurs here. */
|
|
34
38
|
export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies> = {}): Router {
|
|
35
39
|
const deps = { ...defaults, ...overrides }, router = Router()
|
|
40
|
+
const listForBoard = overrides.listDated ?? (overrides.list ? deps.list : () => listBoardWithDates())
|
|
36
41
|
const board = deps.readBoard ?? createWorkBoardReader(() => deps.list())
|
|
37
42
|
router.use('/work-board', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
38
43
|
const fail = (res: import('express').Response, e: unknown) => {
|
|
@@ -80,7 +85,7 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
80
85
|
router.get('/work-board', async (_req, res) => {
|
|
81
86
|
try {
|
|
82
87
|
const read = board.begin()
|
|
83
|
-
const [raw, capabilities] = await Promise.all([
|
|
88
|
+
const [raw, capabilities] = await Promise.all([listForBoard(), deps.capabilities()])
|
|
84
89
|
board.prime(raw, read)
|
|
85
90
|
// 6.59.0: taskRevision is this task's own revision (COS Control's taskSnapshot), what a handoff request names.
|
|
86
91
|
const tasks = raw.map(row => ({ ...row, workStage: row.checked ? 'complete' : row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned'), workIdentity: row.workIdentity || row.id, meetingRefs: row.meetingRefs ?? [], taskRevision: controlSnapshotRevision(row) }))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/work/search (6.65.0): which board card the user means, by meaning. Auth comes from the parent /api mount.
|
|
3
|
+
*
|
|
4
|
+
* Body `{ query, domain?, scope?, ids? }`. The cards come from the board read /api/tasks serves; the client sends a
|
|
5
|
+
* view (domain, scope) or exact card ids, never task text. A search that cannot run answers 200 with
|
|
6
|
+
* `{ available: false, reason, message }`, so Control keeps its word search. See lib/work-search.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { Router } from 'express'
|
|
9
|
+
import { listBoard } from '../lib/task-store.js'
|
|
10
|
+
import { isSafeDomainName } from '../lib/domains.js'
|
|
11
|
+
import { queryLength, sharedWorkSearchJevClient, selectCandidates, unavailable, workSearchEnabled, WorkSearcher, WORK_SEARCH_LIMITS,
|
|
12
|
+
WORK_SEARCH_SCOPES, type WorkSearchResult, type WorkSearchScope } from '../lib/work-search.js'
|
|
13
|
+
|
|
14
|
+
export interface WorkSearchRouteDependencies {
|
|
15
|
+
searcher: Pick<WorkSearcher, 'search'>
|
|
16
|
+
list: () => ReturnType<typeof listBoard>
|
|
17
|
+
enabled: () => boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BODY_KEYS = new Set(['query', 'domain', 'scope', 'ids'])
|
|
21
|
+
const TASK_ID = /^[a-f0-9]{12}$/
|
|
22
|
+
/** One log line per degrade reason per 10 minutes: Control falls back quietly, so the server says why. */
|
|
23
|
+
export const WORK_SEARCH_LOG_EVERY_MS = 10 * 60_000
|
|
24
|
+
|
|
25
|
+
export function createWorkSearchRouter(overrides: Partial<WorkSearchRouteDependencies> = {}): Router {
|
|
26
|
+
const deps: WorkSearchRouteDependencies = { searcher: new WorkSearcher(sharedWorkSearchJevClient()), list: () => listBoard(),
|
|
27
|
+
enabled: () => workSearchEnabled(), ...overrides }
|
|
28
|
+
const router = Router()
|
|
29
|
+
const logged = new Map<string, number>()
|
|
30
|
+
const answer = (res: import('express').Response, result: WorkSearchResult) => {
|
|
31
|
+
if (!result.available) {
|
|
32
|
+
const at = Date.now(), last = logged.get(result.reason)
|
|
33
|
+
if (last === undefined || at - last >= WORK_SEARCH_LOG_EVERY_MS) {
|
|
34
|
+
logged.set(result.reason, at)
|
|
35
|
+
console.warn(`[work-search] meaning search unavailable: ${result.reason}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return res.json(result)
|
|
39
|
+
}
|
|
40
|
+
router.use('/work/search', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
41
|
+
|
|
42
|
+
router.post('/work/search', async (req, res) => {
|
|
43
|
+
const body = req.body
|
|
44
|
+
const bad = () => res.status(400).json({ error: { code: 'invalid_search_request',
|
|
45
|
+
message: `Send a query of ${WORK_SEARCH_LIMITS.queryMin} to ${WORK_SEARCH_LIMITS.queryMax} characters, and optionally a domain, a scope or card ids.` } })
|
|
46
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !BODY_KEYS.has(k))) return bad()
|
|
47
|
+
const query = typeof body.query === 'string' ? body.query.trim() : ''
|
|
48
|
+
const length = queryLength(query)
|
|
49
|
+
if (length < WORK_SEARCH_LIMITS.queryMin || length > WORK_SEARCH_LIMITS.queryMax) return bad()
|
|
50
|
+
if (body.domain !== undefined && (typeof body.domain !== 'string' || !isSafeDomainName(body.domain))) return bad()
|
|
51
|
+
if (body.scope !== undefined && !(WORK_SEARCH_SCOPES as readonly unknown[]).includes(body.scope)) return bad()
|
|
52
|
+
if (body.ids !== undefined && (!Array.isArray(body.ids) || body.ids.some((id: unknown) => typeof id !== 'string' || !TASK_ID.test(id)))) return bad()
|
|
53
|
+
if (!deps.enabled()) return answer(res, unavailable('search_off'))
|
|
54
|
+
const ids: string[] | undefined = body.ids ? [...new Set<string>(body.ids)] : undefined
|
|
55
|
+
// Refused before reading the board: a Choice cannot hold them, and a cut list would silently miss cards.
|
|
56
|
+
if (ids && ids.length > WORK_SEARCH_LIMITS.maxCandidates) return answer(res, unavailable('too_many_candidates'))
|
|
57
|
+
let rows: Awaited<ReturnType<typeof listBoard>>
|
|
58
|
+
try { rows = await deps.list() } catch (e) {
|
|
59
|
+
console.error('[work-search] board read failed:', e instanceof Error ? e.message : e)
|
|
60
|
+
return res.status(503).json({ error: { code: 'work_board_unavailable', message: 'Work board is unavailable. Refresh before searching.' } })
|
|
61
|
+
}
|
|
62
|
+
const candidates = selectCandidates(rows, { domain: body.domain, scope: body.scope as WorkSearchScope | undefined, ids })
|
|
63
|
+
if (!candidates) return answer(res, unavailable('too_many_candidates'))
|
|
64
|
+
try {
|
|
65
|
+
return answer(res, await deps.searcher.search(query, candidates))
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error('[work-search] search failed:', e instanceof Error ? e.message : e)
|
|
68
|
+
return answer(res, unavailable('jev_unavailable'))
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
return router
|
|
72
|
+
}
|