@gotcos/glasses-server 6.57.1 → 6.58.1
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/CHANGELOG.md +47 -0
- package/docs/work-intake.md +2 -0
- package/package.json +1 -1
- package/server/index.ts +2 -1
- package/server/lib/client-instance-claim.ts +146 -15
- package/server/lib/work-completion.ts +98 -0
- package/server/routes/client-instance.ts +12 -3
- package/server/routes/jev.ts +42 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
## 6.58.1
|
|
2
|
+
|
|
3
|
+
### A copy that lost its audio no longer holds the ring
|
|
4
|
+
|
|
5
|
+
- 2026-09-29, 07:36 to 07:51, glasses 6.9.558 on 6.57.1, the HC standup while driving: a hidden copy of COS (`mumnr7cf`) lost its audio at 07:38:19 (`meeting.audio.silent`, the bridge probe alive, the glasses connected) and went on claiming `recording: true`. Its timers were throttled: 24 log rows in 14 minutes, gaps of 30 s to 208 s. A new copy started the meeting at 07:38:48 and another at 07:45:10; each was told to yield within a second, which stopped its recording. Only the copy opened at 07:50:46 took the ring. About 12.5 minutes (07:38:19 to 07:51:02) never reached the Mac.
|
|
6
|
+
- The cause: an owner's own `recording` word held the ring for as long as it kept claiming (QA round 3's allowance for uploads that lag after an outage), and nothing checked the word against its chunks.
|
|
7
|
+
- **A copy that says it is not recording is believed over its own chunks.** Before, any chunk of its own in the last 30 s held the ring, including chunks committed before its audio stopped and still uploading or replayed. This applies to a claimant taking the ring, and to an owner holding it while that owner keeps claiming (45 s): a copy's start announcement is one claim with a 1.5 s deadline while its chunks get 30 s, so an older `false` can outlive a start the Mac never heard, and then the chunks speak for it. Glasses 6.9.560 says so within seconds of losing its audio. Only a claim that carries the `recording` field counts (glasses 6.9.510+); older copies keep the 6.50.4 rule.
|
|
8
|
+
- **A copy's word alone holds for 150 s (`CLIENT_INSTANCE_WORD_HOLD_MS`) after its newest proof:** its own newest chunk, the start of its recording claims, or a grace after a gap. The worst gap between two chunks from a copy that is recording is one failed upload (the 30 s timeout) plus the longest backoff (60 s); the hold allows one more backoff on top, and is derived from those two constants. After a gap of more than two ticks (`CLIENT_INSTANCE_GRACE_GAP_MS`, an outage or a sleep) a copy still recording gets one fresh hold for its uploads to catch up, and another only after a chunk of its own has arrived since: the 9/29 zombie's gappy claims would otherwise have renewed it forever. The same test applies to a copy taking the ring and to one holding it.
|
|
9
|
+
- **What each copy has said is kept per copy**, across changes of owner. Without that, a copy that lost the ring and took it back started a fresh clock, and a silent copy and an idle newer one traded the ring every few minutes for a whole meeting. Now the ring moves once. Glasses 6.9.560 then stands aside quietly, keeping its meeting until its audio returns or another copy records.
|
|
10
|
+
- **Known limit for builds before 6.9.560:** once the ring has moved, the silent copy keeps its meeting and, told to yield by an owner that is not recording, keeps answering the ring (6.9.510's rule) alongside the new owner until that owner starts recording, when it is told to yield and saves. The Mac could end that sooner only by telling it the new owner records when it does not.
|
|
11
|
+
- For the builds already out (the Even Hub 6.9.538 included), the hold is bounded, not removed: a copy opened while the silent copy's last chunks are under 30 s old, or its word under 150 s (plus one grace), is still told to yield. From then on the next copy to start the meeting keeps it.
|
|
12
|
+
- Unchanged: a newer copy still waits while the owner's own chunks arrive, a duplicate recording still ends on the claimant, untagged chunks still hold only for an owner that claims, and the 6.52.3 reclaim rules.
|
|
13
|
+
- Tests: the 9/29 timeline over HTTP (the new copy keeps the ring, and the zombie is then told to yield with `captureLive: true`); an older build that never stops saying `recording`, held for exactly the hold after its last chunk; a silent 6.9.538 copy and an idle newer one for 20 minutes (the ring moves once); a real recorder uploading on the worst cadence keeps the ring; a claimant that says `false` never takes on its chunks; a copy with nothing to show cannot take the ring back by saying `recording`; the hold's derivation; the one grace per chunk. Two HTTP fixtures that claimed to be 6.9.509 now omit `recording` as 6.9.509 did. Two review rounds (round 1 FIX FIRST: a claimant's own chunks and a fresh clock on every take-back let the ring alternate, and the 90 s hold had no margin over the uploader; round 2 FIX FIRST: believing an owner's old `false` forever let a second copy record alongside one whose start the Mac missed; all fixed, with a test for each).
|
|
14
|
+
|
|
15
|
+
## 6.58.0
|
|
16
|
+
|
|
17
|
+
### Work: did the session finish the task?
|
|
18
|
+
|
|
19
|
+
- Miles, 2026-09-29: "If a session is responsible for completing multiple tasks and we have evidence of the session receiving the context necessary to complete that task (as well as a confirmation that it has), then we want to be moving those tasks through the Kanban as well automatically."
|
|
20
|
+
- COS Control 0.5.247 follows each Work handoff until the session reports on it.
|
|
21
|
+
- Every handoff asks the agent to make its first reply line `COS-WORK <id>: done: <evidence>`. Control reads it from the session's recent messages (`GET /api/agent-sessions/:provider/:id?turns=N`, served since 6.50.0) and moves the card itself, as far as QA and never to Complete.
|
|
22
|
+
- **New `POST /api/work-board/completion-check`**, the fallback when the session's newest reply since the handoff has no status line and the session is idle.
|
|
23
|
+
- The body is exactly `{domain, id, provider, sessionId}`, plus an optional `after` (an ISO time).
|
|
24
|
+
- The task comes from the board. The replies come from this server's own transcripts: with `after`, every reply written since then, joined and kept to the newest 4,000 characters (`LATEST_REPLY_MAX`); without it, the newest reply.
|
|
25
|
+
- The client never sends work or reply text.
|
|
26
|
+
- Jev answers one choice question (done, not_done or unclear) with a probability.
|
|
27
|
+
- The task is judged against its Done when, or, when it has none, against its own text. No open task has one today. The answer says which, as `basis: "done_when" | "task"`.
|
|
28
|
+
- The answer is advice. Control moves a card only on done at 0.8 or more (`done_when`) or 0.85 (`task`). Confidence is not rounded, so 0.795 never reads as 0.8.
|
|
29
|
+
- **Answers:**
|
|
30
|
+
- `{provider: "jev", verdict, confidence, basis, model, cached}`.
|
|
31
|
+
- Otherwise `{provider: "none", reason}`:
|
|
32
|
+
- `no_reply` when there is no reply;
|
|
33
|
+
- `no_task_text` when the task has no text;
|
|
34
|
+
- any Jev condition (`jev_not_configured`, `jev_cap_reached`, `jev_breaker_open` and the rest);
|
|
35
|
+
- `completion_unavailable` for an unexpected failure, which is logged and answered 200.
|
|
36
|
+
- An unknown task is 404 `task_not_found`, an unknown session 404 `session_not_found`, and any other body 400 `invalid_completion_request`.
|
|
37
|
+
- Answers are cached for 30 minutes per task text, Done when and reply.
|
|
38
|
+
- **Cost:** Control asks at most twice per handoff, once per new reply, and only while the session is idle. The task text is clipped to 1,200 characters, the Done when to 500 and the replies to 4,000, so one check is at most about 2,500 input tokens, inside the shared daily Jev cap (`COS_JEV_DAILY_TOKENS`). The reply text goes to TypeSafe unredacted, as with every Jev question about Work.
|
|
39
|
+
- **Other changes:**
|
|
40
|
+
- `server/routes/jev.ts` finds a task through one `findTask` (Work identity first, then row id) for both suggestions and completion checks.
|
|
41
|
+
- The local Work candidate answers completion checks with no session, so it never reads this Mac's transcripts.
|
|
42
|
+
- **Tests:**
|
|
43
|
+
- The checker: policy, unrounded confidence, basis, the clipping of task, Done when and reply, cache and reasons.
|
|
44
|
+
- The replies reader, over a fixture transcript: `after` window, newest kept, unknown session.
|
|
45
|
+
- The route: board and transcripts as the only sources, `after` passed through and validated, exact bodies, 404s, a failure answered as advice.
|
|
46
|
+
- The mutation gate adds 16 mutants and runs `work-completion.test.ts`.
|
|
47
|
+
|
|
1
48
|
## 6.57.1
|
|
2
49
|
|
|
3
50
|
### Session suggestions for meeting reviews
|
package/docs/work-intake.md
CHANGED
|
@@ -32,3 +32,5 @@ The journal is `work-intake/intake.json` in the server data directory, owned by
|
|
|
32
32
|
`POST /api/jev-key/set` `{ key }` validates the key against TypeSafe and saves it to `data/jev-key.json`; `GET /api/jev-key/status` returns `{ configured, source, usedToday, dailyCap, breakerOpenUntil, lastError }` and never the key; `DELETE /api/jev-key` removes the saved key. `POST /api/work-board/session-recommendation` `{ domain, id, sessions: [{ id, provider, title, summary? }] }` returns `{ provider: "jev", action: "continue" | "fork" | "new", sessionId, confidence, reason, alternatives }` or `{ provider: "none", reason }`. The COS meeting producer reads the same saved key when its own `.env` has none.
|
|
33
33
|
|
|
34
34
|
Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{ reviewId, sessions }` for a meeting review (`reviewId` = `wr_` + 32 hex). The server reads the review's meeting title and reviewed follow-up from its own store; the client never sends work text. Errors: 400 `invalid_review_request` (bad id or session list), 404 `review_not_found`, 404 `reviews_unavailable` (reviews off), 503 `review_store_unavailable` (defensive; the stock lookup reads memory).
|
|
35
|
+
|
|
36
|
+
Since 6.58.0 `POST /api/work-board/completion-check` takes exactly `{ domain, id, provider, sessionId }` plus an optional `after` (ISO time). It asks Jev whether the session's replies since `after` (or its newest reply) show the task finished, judged against its Done when or, without one, its own text. The server reads the task from the board and the replies from its own transcripts; the client sends names and a time only. It answers `{ provider: "jev", verdict: "done" | "not_done" | "unclear", confidence, basis: "done_when" | "task", model, cached }` or `{ provider: "none", reason }` (`no_reply`, `no_task_text`, a Jev condition, `completion_unavailable`). Errors: 400 `invalid_completion_request`, 404 `task_not_found`, 404 `session_not_found`. COS Control 0.5.247 asks only while the session is idle and its newest reply since the handoff has no `COS-WORK` status line, at most twice per handoff, and moves a card to QA only on done at 0.8 (0.85 without a Done when).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.58.1",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -629,7 +629,8 @@ app.use('/api', createWorkBoardRouter())
|
|
|
629
629
|
// Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
|
|
630
630
|
const workIntakeStore = createOptionalWorkIntakeStore(() => new WorkIntakeStore(dataPath('work-intake')))
|
|
631
631
|
app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
|
|
632
|
-
// Jev key (Control Settings)
|
|
632
|
+
// Jev key (Control Settings), Continue/Fork/New session recommendations for Work tasks (6.57.0) and meeting reviews
|
|
633
|
+
// (6.57.1), and the Work completion check (6.58.0).
|
|
633
634
|
app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workReviewRuntime.peek(id) } : {}))
|
|
634
635
|
app.use('/api', queryRouter)
|
|
635
636
|
// The scheduled start-of-day brief: settings, status, run-now. Same auth as
|
|
@@ -35,6 +35,28 @@ export const CLIENT_INSTANCE_AWAKE_MS = CLIENT_INSTANCE_LIVE_MS
|
|
|
35
35
|
* intervals (~6 s each), so a stretch of silence or a slow upload does not end the hold.
|
|
36
36
|
*/
|
|
37
37
|
export const CLIENT_INSTANCE_CAPTURE_LIVE_MS = 30_000
|
|
38
|
+
/** The glasses uploader's longest wait between chunk attempts (cos-glasses-app local-first-meeting-uploader.ts, maxBackoffMs). */
|
|
39
|
+
export const GLASSES_UPLOAD_MAX_BACKOFF_MS = 60_000
|
|
40
|
+
/** The glasses chunk upload's request timeout (cos-glasses-app api-client.ts). A failed attempt can take this long. */
|
|
41
|
+
export const GLASSES_UPLOAD_TIMEOUT_MS = 30_000
|
|
42
|
+
/**
|
|
43
|
+
* 6.58.1: how long a copy's own `recording` word holds without proof. On 2026-09-29 (glasses
|
|
44
|
+
* 6.9.558) a hidden copy lost its audio at 07:38:19 and kept claiming `recording: true` for 12
|
|
45
|
+
* minutes; two newer copies started the meeting and were each told to stop within a second, and
|
|
46
|
+
* 12.5 minutes were never captured. The word exists for upload lag (QA round 3), which is bounded:
|
|
47
|
+
* the worst gap between two chunk arrivals from a copy that is recording is one failed attempt
|
|
48
|
+
* (the timeout) plus the longest backoff, 90 s. This allows one more backoff on top (QA, 6.58.1:
|
|
49
|
+
* exactly 90 s left no margin for a weak link, an ASR stall on the Mac or throttled timers).
|
|
50
|
+
*/
|
|
51
|
+
export const CLIENT_INSTANCE_WORD_HOLD_MS = GLASSES_UPLOAD_TIMEOUT_MS + 2 * GLASSES_UPLOAD_MAX_BACKOFF_MS
|
|
52
|
+
/**
|
|
53
|
+
* 6.58.1: a gap between an owner's claims longer than two ticks is an outage or a sleep. When the
|
|
54
|
+
* owner comes back still saying `recording`, its word gets one fresh CLIENT_INSTANCE_WORD_HOLD_MS
|
|
55
|
+
* for its chunks to catch up, but only if a chunk of its own arrived since the last such grace:
|
|
56
|
+
* the 2026-09-29 zombie's timers were throttled (gaps of 30 s to 208 s between rows), so a grace
|
|
57
|
+
* on every gap would have held the ring for it indefinitely.
|
|
58
|
+
*/
|
|
59
|
+
export const CLIENT_INSTANCE_GRACE_GAP_MS = 2 * CLIENT_INSTANCE_TICK_MS
|
|
38
60
|
/** App copies whose newest chunk is remembered; the least recent is dropped past this. */
|
|
39
61
|
export const CLIENT_INSTANCE_MAX_TAGGED = 64
|
|
40
62
|
/** A boot time this far past the server clock is a phone clock set wrong, not a newer copy. */
|
|
@@ -45,15 +67,41 @@ export interface ClientInstanceClaim {
|
|
|
45
67
|
bootAt: number
|
|
46
68
|
version: string
|
|
47
69
|
/**
|
|
48
|
-
* The copy says it is recording (glasses 6.9.
|
|
70
|
+
* The copy says it is recording (glasses 6.9.510+; false when absent). Chunk arrival
|
|
49
71
|
* alone lags: after an outage the uploader backs off up to 60 s, and only a visible page
|
|
50
72
|
* or a display reconnect resets it, which a hidden recording copy never gets (QA round 3).
|
|
51
73
|
*/
|
|
52
74
|
recording: boolean
|
|
75
|
+
/**
|
|
76
|
+
* 6.58.1: the claim carried a `recording` field at all (glasses 6.9.510+). Only then is a
|
|
77
|
+
* `recording: false` a statement; from older copies it is merely absent.
|
|
78
|
+
*/
|
|
79
|
+
recordingReported?: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 6.58.1: what one copy has said about recording, kept per copy (not per owner) so it survives
|
|
84
|
+
* the ring moving between copies. Before this was per copy, a copy that lost the ring and took
|
|
85
|
+
* it back started a fresh clock each time, and a silent 6.9.538 copy and an idle newer one
|
|
86
|
+
* traded the ring every few minutes for a whole meeting, both answering the ring (QA, 6.58.1).
|
|
87
|
+
*/
|
|
88
|
+
export interface CopyWord {
|
|
89
|
+
recording: boolean
|
|
90
|
+
recordingReported: boolean
|
|
91
|
+
/** When this copy's current run of `recording: true` claims began. */
|
|
92
|
+
recordingSince?: number
|
|
93
|
+
/** When this copy last got a grace after a gap in its claims (see CLIENT_INSTANCE_GRACE_GAP_MS). */
|
|
94
|
+
graceAt?: number
|
|
95
|
+
/** When this copy last claimed, under any verdict. */
|
|
96
|
+
seenAt: number
|
|
53
97
|
}
|
|
54
98
|
|
|
55
99
|
export interface ClientInstanceOwner extends ClientInstanceClaim {
|
|
56
100
|
seenAt: number
|
|
101
|
+
/** 6.58.1: the owner's CopyWord.recordingSince. */
|
|
102
|
+
recordingSince?: number
|
|
103
|
+
/** 6.58.1: the owner's CopyWord.graceAt. */
|
|
104
|
+
graceAt?: number
|
|
57
105
|
}
|
|
58
106
|
|
|
59
107
|
export type ClientInstanceVerdict = 'owner' | 'yield'
|
|
@@ -75,6 +123,10 @@ export interface CaptureEvidence {
|
|
|
75
123
|
owner: boolean
|
|
76
124
|
claimant: boolean
|
|
77
125
|
untagged: boolean
|
|
126
|
+
/** 6.58.1: when the owner's own newest tagged chunk arrived, however long ago; absent: never (or forgotten). */
|
|
127
|
+
ownerChunkAt?: number
|
|
128
|
+
/** 6.58.1: the same for the claimant. */
|
|
129
|
+
claimantChunkAt?: number
|
|
78
130
|
}
|
|
79
131
|
|
|
80
132
|
export const NO_CAPTURE: CaptureEvidence = { owner: false, claimant: false, untagged: false }
|
|
@@ -97,10 +149,14 @@ export class ClientChunkLedger {
|
|
|
97
149
|
|
|
98
150
|
evidence(device: string, ownerId: string, claimId: string, now: number): CaptureEvidence {
|
|
99
151
|
const live = (at: number | undefined) => at !== undefined && now - at < CLIENT_INSTANCE_CAPTURE_LIVE_MS
|
|
152
|
+
const ownerChunkAt = this.tagged.get(ownerId)
|
|
153
|
+
const claimantChunkAt = this.tagged.get(claimId)
|
|
100
154
|
return {
|
|
101
|
-
owner: live(
|
|
102
|
-
claimant: live(
|
|
155
|
+
owner: live(ownerChunkAt),
|
|
156
|
+
claimant: live(claimantChunkAt),
|
|
103
157
|
untagged: live(this.untagged.get(device)),
|
|
158
|
+
...(ownerChunkAt !== undefined ? { ownerChunkAt } : {}),
|
|
159
|
+
...(claimantChunkAt !== undefined ? { claimantChunkAt } : {}),
|
|
104
160
|
}
|
|
105
161
|
}
|
|
106
162
|
}
|
|
@@ -111,10 +167,68 @@ export class ClientChunkLedger {
|
|
|
111
167
|
* own last claim saying `recording`, and untagged chunks from the device (which could be
|
|
112
168
|
* anyone's), count only while it is still claiming: a dead owner must never be pinned by
|
|
113
169
|
* a stale flag or by the chunks of the copy that replaced it (QA round 2).
|
|
170
|
+
*
|
|
171
|
+
* 6.58.1, two limits from the 2026-09-29 zombie (see CLIENT_INSTANCE_WORD_HOLD_MS):
|
|
172
|
+
* - An owner still claiming that SAYS it is not recording is believed over its trailing chunks.
|
|
173
|
+
* Glasses 6.9.560 says so within seconds of its audio stopping; the chunks it had already
|
|
174
|
+
* committed can keep arriving and would hold the ring for a copy with nothing left to record.
|
|
175
|
+
* Only while it claims (CLIENT_INSTANCE_LIVE_MS): a copy's start announcement is one claim with
|
|
176
|
+
* a 1.5 s deadline while its chunks get 30 s, so a `false` from before a meeting can outlive a
|
|
177
|
+
* start the Mac never heard, and its chunks must then speak for it (QA round 2: believing the
|
|
178
|
+
* old `false` let a second copy record alongside). Only a claim that carries the field counts.
|
|
179
|
+
* - Its word alone holds for CLIENT_INSTANCE_WORD_HOLD_MS after its newest proof (wordBacked).
|
|
114
180
|
*/
|
|
115
181
|
export function ownerIsRecording(owner: ClientInstanceOwner, evidence: CaptureEvidence, now: number): boolean {
|
|
116
|
-
|
|
117
|
-
|
|
182
|
+
const claiming = now - owner.seenAt <= CLIENT_INSTANCE_LIVE_MS
|
|
183
|
+
if (evidence.owner && !(claiming && saysNotRecording(owner))) return true
|
|
184
|
+
if (!claiming) return false
|
|
185
|
+
if (evidence.untagged) return true
|
|
186
|
+
return wordBacked(owner, evidence.ownerChunkAt, now)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 6.58.1: the claim carried `recording: false` explicitly (glasses 6.9.510+). */
|
|
190
|
+
export function saysNotRecording(claim: Pick<ClientInstanceClaim, 'recording' | 'recordingReported'>): boolean {
|
|
191
|
+
return claim.recordingReported === true && !claim.recording
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 6.58.1: a copy's own `recording: true` still counts: its newest proof (its own newest chunk,
|
|
196
|
+
* the start of its run of recording claims, or its one grace after a gap) is no older than
|
|
197
|
+
* CLIENT_INSTANCE_WORD_HOLD_MS. The same rule for an owner holding the ring and a claimant
|
|
198
|
+
* taking it, so a copy with nothing to show cannot take the ring back by saying so (QA, 6.58.1).
|
|
199
|
+
*/
|
|
200
|
+
export function wordBacked(word: Pick<CopyWord, 'recording' | 'recordingSince' | 'graceAt'>, chunkAt: number | undefined, now: number): boolean {
|
|
201
|
+
if (!word.recording) return false
|
|
202
|
+
const provenAt = Math.max(chunkAt ?? 0, word.recordingSince ?? 0, word.graceAt ?? 0)
|
|
203
|
+
return now - provenAt <= CLIENT_INSTANCE_WORD_HOLD_MS
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 6.58.1: a copy's word after its claim at `now`. `recordingSince` starts when its claims start
|
|
208
|
+
* saying `recording` and clears when they stop. A claim that comes back after a gap longer than
|
|
209
|
+
* CLIENT_INSTANCE_GRACE_GAP_MS, still recording, gets a grace for its uploads to catch up, once
|
|
210
|
+
* per chunk of its own: the first grace is free, and another needs a chunk newer than the last.
|
|
211
|
+
* `chunkAt` is this copy's own newest chunk.
|
|
212
|
+
*/
|
|
213
|
+
export function nextCopyWord(prev: CopyWord | undefined, claim: ClientInstanceClaim, now: number, chunkAt?: number): CopyWord {
|
|
214
|
+
const recordingReported = claim.recordingReported === true
|
|
215
|
+
if (!claim.recording) return { recording: false, recordingReported, seenAt: now }
|
|
216
|
+
if (!prev || !prev.recording || prev.recordingSince === undefined) return { recording: true, recordingReported, recordingSince: now, seenAt: now }
|
|
217
|
+
const cameBack = now - prev.seenAt > CLIENT_INSTANCE_GRACE_GAP_MS
|
|
218
|
+
const chunkSinceGrace = prev.graceAt === undefined || (chunkAt ?? 0) > prev.graceAt
|
|
219
|
+
const graceAt = cameBack && chunkSinceGrace ? now : prev.graceAt
|
|
220
|
+
return { recording: true, recordingReported, recordingSince: prev.recordingSince, ...(graceAt !== undefined ? { graceAt } : {}), seenAt: now }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The owner's word, as a CopyWord. */
|
|
224
|
+
function wordOf(owner: ClientInstanceOwner): CopyWord {
|
|
225
|
+
return {
|
|
226
|
+
recording: owner.recording,
|
|
227
|
+
recordingReported: owner.recordingReported === true,
|
|
228
|
+
...(owner.recordingSince !== undefined ? { recordingSince: owner.recordingSince } : {}),
|
|
229
|
+
...(owner.graceAt !== undefined ? { graceAt: owner.graceAt } : {}),
|
|
230
|
+
seenAt: owner.seenAt,
|
|
231
|
+
}
|
|
118
232
|
}
|
|
119
233
|
|
|
120
234
|
/** A body the route may act on, or null. Never throws. */
|
|
@@ -127,7 +241,7 @@ export function parseClientInstanceClaim(body: unknown, now = Date.now()): Clien
|
|
|
127
241
|
// as long as the clock stayed wrong (QA, 6.50.3).
|
|
128
242
|
if (o.bootAt > now + CLIENT_INSTANCE_MAX_FUTURE_BOOT_MS) return null
|
|
129
243
|
const version = typeof o.version === 'string' ? o.version.slice(0, 32) : ''
|
|
130
|
-
return { id: o.id, bootAt: o.bootAt, version, recording: o.recording === true }
|
|
244
|
+
return { id: o.id, bootAt: o.bootAt, version, recording: o.recording === true, recordingReported: typeof o.recording === 'boolean' }
|
|
131
245
|
}
|
|
132
246
|
|
|
133
247
|
function isNewer(a: ClientInstanceClaim, b: ClientInstanceClaim): boolean {
|
|
@@ -142,27 +256,44 @@ export function arbitrateClientInstance(
|
|
|
142
256
|
evidence: CaptureEvidence = NO_CAPTURE,
|
|
143
257
|
/** When this claimant last claimed (any verdict), or undefined: never seen. */
|
|
144
258
|
claimantLastSeenAt?: number,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
259
|
+
/**
|
|
260
|
+
* 6.58.1: this claimant's word before this claim (the route keeps one per copy). Undefined:
|
|
261
|
+
* the owner's own word when the claimant is the owner, otherwise a copy never heard from.
|
|
262
|
+
*/
|
|
263
|
+
claimantWord?: CopyWord,
|
|
264
|
+
): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean; word: CopyWord } {
|
|
265
|
+
const isOwner = owner !== null && owner.id === claim.id
|
|
266
|
+
const chunkAt = isOwner ? evidence.ownerChunkAt : evidence.claimantChunkAt
|
|
267
|
+
const word = nextCopyWord(claimantWord ?? (isOwner ? wordOf(owner) : undefined), claim, now, chunkAt)
|
|
268
|
+
const fresh: ClientInstanceOwner = {
|
|
269
|
+
...claim,
|
|
270
|
+
seenAt: now,
|
|
271
|
+
...(word.recordingSince !== undefined ? { recordingSince: word.recordingSince } : {}),
|
|
272
|
+
...(word.graceAt !== undefined ? { graceAt: word.graceAt } : {}),
|
|
273
|
+
}
|
|
274
|
+
const decide = (o: ClientInstanceOwner, verdict: ClientInstanceVerdict, took: boolean) => ({ owner: o, verdict, took, word })
|
|
275
|
+
if (!owner) return decide(fresh, 'owner', true)
|
|
276
|
+
if (isOwner) return decide(fresh, 'owner', false)
|
|
149
277
|
// 6.50.4: the yielding copy stops its recording (that is how a duplicate ends), so the
|
|
150
278
|
// ring must never move in a way that stops the only recording.
|
|
151
279
|
// (1) The claimant is recording and the owner is not: the claimant is the meeting.
|
|
152
280
|
// Reopened offline, the Mac still names the dead copy from before; when signal returns,
|
|
153
281
|
// the new copy's chunks land first and its claim must not be told to stop (QA round 2).
|
|
154
282
|
// Its chunks or its own word: after an outage the uploads lag the claims (QA round 3).
|
|
155
|
-
|
|
283
|
+
// 6.58.1: the claimant's chunks count unless it says it is not recording, and its word only
|
|
284
|
+
// while it is backed (wordBacked), the same tests the owner meets.
|
|
285
|
+
const claimantRecords = (evidence.claimant && !saysNotRecording(claim)) || wordBacked(word, chunkAt, now)
|
|
286
|
+
if (claimantRecords && !ownerIsRecording(owner, evidence, now)) return decide(fresh, 'owner', true)
|
|
156
287
|
// (2) The owner is recording: a newer copy waits, however quiet the owner's claims.
|
|
157
288
|
// Reopening COS on a blank phone page mid-meeting ended the meeting on 6.50.3 (QA).
|
|
158
289
|
// The ring moves on the newer copy's first claim after the meeting stops.
|
|
159
|
-
if (ownerIsRecording(owner, evidence, now)) return
|
|
160
|
-
if (isNewer(claim, owner)) return
|
|
290
|
+
if (ownerIsRecording(owner, evidence, now)) return decide(owner, 'yield', false)
|
|
291
|
+
if (isNewer(claim, owner)) return decide(fresh, 'owner', true)
|
|
161
292
|
// 6.52.3: an older boot takes a quiet ring back only after CLIENT_INSTANCE_RECLAIM_MS,
|
|
162
293
|
// and only while it has itself been claiming (awake), never on the first tick after a
|
|
163
294
|
// gap of its own. A dead newer copy is replaced when the user reopens COS (a newer boot
|
|
164
295
|
// still takes at once); this rule is for the copy that is merely quiet.
|
|
165
296
|
const claimantAwake = claimantLastSeenAt !== undefined && now - claimantLastSeenAt <= CLIENT_INSTANCE_AWAKE_MS
|
|
166
|
-
if (now - owner.seenAt > CLIENT_INSTANCE_RECLAIM_MS && claimantAwake) return
|
|
167
|
-
return
|
|
297
|
+
if (now - owner.seenAt > CLIENT_INSTANCE_RECLAIM_MS && claimantAwake) return decide(fresh, 'owner', true)
|
|
298
|
+
return decide(owner, 'yield', false)
|
|
168
299
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did an agent session finish a Work task? (6.58.0)
|
|
3
|
+
*
|
|
4
|
+
* Control 0.5.247 follows a Work handoff until the session reports on it. Every handoff asks the agent to start its
|
|
5
|
+
* reply with a status line (`COS-WORK <id>: done: <evidence>`). When the session's newest reply since the handoff
|
|
6
|
+
* carries none and the session has gone idle, Control asks this (at most twice per handoff, once per reply). Jev reads
|
|
7
|
+
* the session's replies since the handoff arrived (`after`) against the task's Done when, or, when the task has none
|
|
8
|
+
* (most do), against the task itself. It answers done, not_done or unclear with a probability. Control moves the card
|
|
9
|
+
* to QA only on done at 0.8 or more (0.85 without a Done when), and never to Complete: that stays the user's.
|
|
10
|
+
*
|
|
11
|
+
* The task comes from the board and the replies from this server's own session transcripts. The client sends names
|
|
12
|
+
* and a time only.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { estimateJevTokens, JevError, type JevClient } from './jev.js'
|
|
16
|
+
import { agentSessionRoots, findAgentSessionFile, LATEST_REPLY_MAX, parseAgentSession, type AgentProvider } from './agent-session-store.js'
|
|
17
|
+
import { readRecentSessionTurns, SESSION_TURNS_MAX, type SessionTurn } from './agent-session-turns.js'
|
|
18
|
+
|
|
19
|
+
export const COMPLETION_LIMITS = { task: 1200, doneWhen: 500, reply: LATEST_REPLY_MAX, cacheMs: 30 * 60_000, cacheEntries: 200 } as const
|
|
20
|
+
export const COMPLETION_CHOICES = ['done', 'not_done', 'unclear'] as const
|
|
21
|
+
export type CompletionChoice = typeof COMPLETION_CHOICES[number]
|
|
22
|
+
|
|
23
|
+
export interface CompletionInput { task: { text: string; doneWhen: string }; reply: string }
|
|
24
|
+
/** `basis`: what the reply was read against, the task's Done when or (without one) the task itself. */
|
|
25
|
+
export type CompletionVerdict =
|
|
26
|
+
| { provider: 'jev'; model: string; verdict: CompletionChoice; confidence: number; basis: 'done_when' | 'task'; cached: boolean }
|
|
27
|
+
| { provider: 'none'; reason: string }
|
|
28
|
+
|
|
29
|
+
const AGAINST_DONE_WHEN = "Does the agent's reply in `reply` show that the task in `task` is finished, as `task.doneWhen` defines finished? "
|
|
30
|
+
+ 'Choose done only when the reply reports the work complete and checked against that finish line.'
|
|
31
|
+
const AGAINST_TASK = "Does the agent's reply in `reply` show that the task in `task.text` is finished? "
|
|
32
|
+
+ 'Choose done only when the reply reports that exact work complete and checked, not merely started or planned.'
|
|
33
|
+
const REST = ' A plan, a partial result, a question for the user, a blocker, or a promise to do it next is not_done. '
|
|
34
|
+
+ 'Choose unclear when the reply does not say enough to tell.'
|
|
35
|
+
|
|
36
|
+
const clip = (text: string, max: number) => text.trim().slice(0, max)
|
|
37
|
+
|
|
38
|
+
/** Pure policy over Jev's answer: the most probable choice and its probability (unrounded: a threshold decides on it). */
|
|
39
|
+
export function decideCompletion(probabilities: Record<string, number> | undefined): { verdict: CompletionChoice; confidence: number } {
|
|
40
|
+
const rows = Object.entries(probabilities ?? {})
|
|
41
|
+
if (!rows.length || rows.some(([k, v]) => !(COMPLETION_CHOICES as readonly string[]).includes(k) || typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1)) {
|
|
42
|
+
throw new JevError('jev_bad_answer', 'Jev answered outside done, not_done and unclear')
|
|
43
|
+
}
|
|
44
|
+
const [verdict, confidence] = rows.sort((a, b) => b[1] - a[1])[0] as [CompletionChoice, number]
|
|
45
|
+
return { verdict, confidence }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The replies among `turns` written at or after `afterMs`, joined oldest first, keeping the newest `max` characters. */
|
|
49
|
+
export function joinRepliesSince(turns: SessionTurn[], afterMs: number, max: number = COMPLETION_LIMITS.reply): string {
|
|
50
|
+
const joined = turns.filter(t => t.role === 'assistant' && t.at !== undefined && Date.parse(t.at) >= afterMs).map(t => t.text).join('\n\n')
|
|
51
|
+
return joined.length <= max ? joined : `\u2026${joined.slice(joined.length - (max - 1))}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SessionFileFinder = (provider: AgentProvider, sessionId: string) => Promise<string | null>
|
|
55
|
+
const findInHome: SessionFileFinder = (provider, sessionId) => findAgentSessionFile(provider, sessionId, agentSessionRoots())
|
|
56
|
+
|
|
57
|
+
/** What a completion check judges: the session's replies since `afterMs`, or its newest reply when no time is given.
|
|
58
|
+
* Null when the session is not on this Mac. */
|
|
59
|
+
export async function readRepliesSince(provider: AgentProvider, sessionId: string, afterMs: number | null,
|
|
60
|
+
find: SessionFileFinder = findInHome): Promise<string | null> {
|
|
61
|
+
const path = await find(provider, sessionId)
|
|
62
|
+
if (!path) return null
|
|
63
|
+
if (afterMs === null) return (await parseAgentSession(provider, path)).latest_reply || ''
|
|
64
|
+
return joinRepliesSince((await readRecentSessionTurns(provider, path, SESSION_TURNS_MAX)).turns, afterMs)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class CompletionChecker {
|
|
68
|
+
private cache = new Map<string, { at: number; value: Extract<CompletionVerdict, { provider: 'jev' }> }>()
|
|
69
|
+
constructor(private readonly jev: JevClient, private readonly now: () => number = () => Date.now()) {}
|
|
70
|
+
|
|
71
|
+
async check(input: CompletionInput): Promise<CompletionVerdict> {
|
|
72
|
+
const state = { task: { text: clip(input.task.text, COMPLETION_LIMITS.task), doneWhen: clip(input.task.doneWhen, COMPLETION_LIMITS.doneWhen) },
|
|
73
|
+
reply: clip(input.reply, COMPLETION_LIMITS.reply) }
|
|
74
|
+
const basis = state.task.doneWhen ? 'done_when' as const : 'task' as const
|
|
75
|
+
if (!state.task.doneWhen && !state.task.text) return { provider: 'none', reason: 'no_task_text' }
|
|
76
|
+
if (!state.reply) return { provider: 'none', reason: 'no_reply' }
|
|
77
|
+
const key = createHash('sha256').update(JSON.stringify(state)).digest('hex')
|
|
78
|
+
const hit = this.cache.get(key)
|
|
79
|
+
if (hit && this.now() - hit.at < COMPLETION_LIMITS.cacheMs) return { ...hit.value, cached: true }
|
|
80
|
+
const questions = {
|
|
81
|
+
finished: { type: 'choice', instructions: (basis === 'done_when' ? AGAINST_DONE_WHEN : AGAINST_TASK) + REST, criteria: {
|
|
82
|
+
done: basis === 'done_when' ? 'The reply reports the task finished and checked against its Done when.' : 'The reply reports this task finished and checked.',
|
|
83
|
+
not_done: 'The reply shows work remaining, a plan, a question, a blocker, or a partial result.',
|
|
84
|
+
unclear: 'The reply does not say enough to tell.',
|
|
85
|
+
} },
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const reply = await this.jev.ask(state, questions, estimateJevTokens({ state, questions }))
|
|
89
|
+
const value = { provider: 'jev' as const, model: reply.model ?? 'jev', cached: false, basis, ...decideCompletion(reply.answers.finished?.probabilities) }
|
|
90
|
+
if (this.cache.size >= COMPLETION_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
|
|
91
|
+
this.cache.set(key, { at: this.now(), value })
|
|
92
|
+
return value
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (e instanceof JevError) return { provider: 'none', reason: e.code }
|
|
95
|
+
throw e
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -21,8 +21,8 @@ import {
|
|
|
21
21
|
parseClientInstanceClaim,
|
|
22
22
|
parseInstanceTag,
|
|
23
23
|
ClientChunkLedger,
|
|
24
|
-
NO_CAPTURE,
|
|
25
24
|
type ClientInstanceOwner,
|
|
25
|
+
type CopyWord,
|
|
26
26
|
} from '../lib/client-instance-claim.js'
|
|
27
27
|
|
|
28
28
|
/** Owners kept at once; the least recently seen device is dropped past this. */
|
|
@@ -58,6 +58,13 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
58
58
|
lastSeen.delete(id); lastSeen.set(id, at)
|
|
59
59
|
while (lastSeen.size > CLIENT_INSTANCE_MAX_SEEN) lastSeen.delete(lastSeen.keys().next().value as string)
|
|
60
60
|
}
|
|
61
|
+
// 6.58.1: what each copy has said about recording, kept across changes of owner (CopyWord).
|
|
62
|
+
// Bounded like lastSeen; a check updates it too (the copy still said it).
|
|
63
|
+
const words = new Map<string, CopyWord>()
|
|
64
|
+
const noteWord = (id: string, word: CopyWord) => {
|
|
65
|
+
words.delete(id); words.set(id, word)
|
|
66
|
+
while (words.size > CLIENT_INSTANCE_MAX_SEEN) words.delete(words.keys().next().value as string)
|
|
67
|
+
}
|
|
61
68
|
router.use((req, _res, next) => {
|
|
62
69
|
if (isMeetingChunkPost(req.method, req.path)) chunks.note(deviceKey(req.socket?.remoteAddress ?? req.ip), parseInstanceTag(req.query?.clientInstance), now())
|
|
63
70
|
next()
|
|
@@ -68,11 +75,13 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
68
75
|
if (!claim) return void res.status(400).json({ error: 'invalid_claim' })
|
|
69
76
|
const device = deviceKey(req.socket?.remoteAddress ?? req.ip)
|
|
70
77
|
const previous = owners.get(device) ?? null
|
|
71
|
-
|
|
78
|
+
// 6.58.1: evidence even with no owner, so a first claim still knows its own newest chunk.
|
|
79
|
+
const evidence = chunks.evidence(device, previous?.id ?? '', claim.id, at)
|
|
72
80
|
const captureLive = previous ? ownerIsRecording(previous, evidence, at) : false
|
|
73
81
|
const claimantLastSeenAt = lastSeen.get(claim.id)
|
|
74
82
|
noteSeen(claim.id, at)
|
|
75
|
-
const result = arbitrateClientInstance(previous, claim, at, evidence, claimantLastSeenAt)
|
|
83
|
+
const result = arbitrateClientInstance(previous, claim, at, evidence, claimantLastSeenAt, words.get(claim.id))
|
|
84
|
+
noteWord(claim.id, result.word)
|
|
76
85
|
if (req.body?.check === true) {
|
|
77
86
|
const shown = previous ?? result.owner
|
|
78
87
|
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true, captureLive })
|
package/server/routes/jev.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { Router } from 'express'
|
|
|
9
9
|
import { existsSync, unlinkSync } from 'node:fs'
|
|
10
10
|
import { JEV_KEY_FILE, saveJevKey, sharedJevClient, validateJevKey, type JevClient } from '../lib/jev.js'
|
|
11
11
|
import { SessionRecommender, parseCandidates } from '../lib/session-recommendation.js'
|
|
12
|
+
import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
|
|
13
|
+
import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
|
|
12
14
|
import { listBoard } from '../lib/task-store.js'
|
|
13
15
|
import { isSafeDomainName } from '../lib/domains.js'
|
|
14
16
|
|
|
@@ -23,13 +25,24 @@ export interface JevRouteDependencies {
|
|
|
23
25
|
save: typeof saveJevKey
|
|
24
26
|
/** Looks a meeting review up by its `wr_` id; throws or returns null when it does not exist. Absent when reviews are off. */
|
|
25
27
|
review?: (id: string) => Promise<ReviewForAdvice | null>
|
|
28
|
+
/** 6.58.0: Jev's reading of a reply against a task's Done when. */
|
|
29
|
+
completion: Pick<CompletionChecker, 'check'>
|
|
30
|
+
/** 6.58.0: a session's replies since a time (or its newest reply) from this server's transcripts; null when the
|
|
31
|
+
* session is not on this Mac. */
|
|
32
|
+
replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
|
|
36
|
+
function findTask<T extends { id: string; workIdentity?: string }>(rows: T[], id: string): T | undefined {
|
|
37
|
+
return rows.find(r => r.workIdentity === id) ?? rows.find(r => r.id === id)
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
|
|
29
41
|
const jev = overrides.jev ?? sharedJevClient()
|
|
30
|
-
const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey,
|
|
42
|
+
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), ...overrides }
|
|
31
44
|
const router = Router()
|
|
32
|
-
router.use(['/jev-key', '/work-board/session-recommendation'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
45
|
+
router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
33
46
|
|
|
34
47
|
router.get('/jev-key/status', (_req, res) => res.json(deps.jev.status()))
|
|
35
48
|
|
|
@@ -91,9 +104,8 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
91
104
|
return res.status(400).json({ error: { code: 'invalid_recommendation_request', message: 'Select an exact task and send at most 80 sessions.' } })
|
|
92
105
|
}
|
|
93
106
|
try {
|
|
94
|
-
// Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash.
|
|
95
107
|
const rows = (await deps.list()).filter(r => r.domain === body.domain)
|
|
96
|
-
const row =
|
|
108
|
+
const row = findTask(rows, body.id)
|
|
97
109
|
if (!row) return res.status(404).json({ error: { code: 'task_not_found', message: 'That task changed or was removed. Refresh Work.' } })
|
|
98
110
|
const meetings = (row.meetingRefs ?? []).map(ref => ref.title ?? '').filter(Boolean)
|
|
99
111
|
return res.json(await deps.recommender.recommend({ task: { text: row.text || row.title || '', domain: row.domain, meetings }, sessions }))
|
|
@@ -102,5 +114,31 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
102
114
|
return res.json({ provider: 'none', reason: 'recommendation_unavailable' }) // advice only: never block the workspace
|
|
103
115
|
}
|
|
104
116
|
})
|
|
117
|
+
/** 6.58.0 (Control 0.5.247): did a session finish a board task? Asked when the session's newest reply since the
|
|
118
|
+
* handoff has no status line and the session is idle. The task comes from the board and the replies from this
|
|
119
|
+
* server's transcripts; the client sends names and, optionally, `after` (judge replies from then on). An answer is
|
|
120
|
+
* advice: Control moves a card to QA only on a confident done, never to Complete. */
|
|
121
|
+
router.post('/work-board/completion-check', async (req, res) => {
|
|
122
|
+
const body = req.body
|
|
123
|
+
const keys = body && typeof body === 'object' && !Array.isArray(body) ? Object.keys(body).filter(k => k !== 'after').sort().join(',') : ''
|
|
124
|
+
const afterMs = body?.after === undefined ? null : typeof body.after === 'string' && body.after.length <= 40 ? Date.parse(body.after) : Number.NaN
|
|
125
|
+
if (keys !== 'domain,id,provider,sessionId' || Number.isNaN(afterMs)
|
|
126
|
+
|| typeof body.domain !== 'string' || !isSafeDomainName(body.domain) || typeof body.id !== 'string' || !/^[a-f0-9]{12}$/.test(body.id)
|
|
127
|
+
|| !['claude', 'codex', 'cursor'].includes(body.provider) || typeof body.sessionId !== 'string' || !isSafeSessionId(body.sessionId)) {
|
|
128
|
+
return res.status(400).json({ error: { code: 'invalid_completion_request', message: 'Select an exact task and session.' } })
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const rows = (await deps.list()).filter(r => r.domain === body.domain)
|
|
132
|
+
const row = findTask(rows, body.id)
|
|
133
|
+
if (!row) return res.status(404).json({ error: { code: 'task_not_found', message: 'That task changed or was removed. Refresh Work.' } })
|
|
134
|
+
const reply = await deps.replies(body.provider as AgentProvider, body.sessionId, afterMs)
|
|
135
|
+
if (reply === null) return res.status(404).json({ error: { code: 'session_not_found', message: 'That session is not on this Mac.' } })
|
|
136
|
+
// A task with no Done when (most tasks) is judged against its own text; the answer says which (`basis`).
|
|
137
|
+
return res.json(await deps.completion.check({ task: { text: row.text || row.title || '', doneWhen: (row.doneWhen ?? '').trim() }, reply }))
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error('[jev] completion check failed:', e instanceof Error ? e.message : e)
|
|
140
|
+
return res.json({ provider: 'none', reason: 'completion_unavailable' }) // advice only: tracking goes on without it
|
|
141
|
+
}
|
|
142
|
+
})
|
|
105
143
|
return router
|
|
106
144
|
}
|