@gotcos/glasses-server 6.57.0 → 6.58.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/CHANGELOG.md +42 -0
- package/docs/work-intake.md +4 -0
- package/package.json +1 -1
- package/server/index.ts +3 -2
- package/server/lib/session-recommendation.ts +4 -2
- package/server/lib/work-completion.ts +98 -0
- package/server/lib/work-review-runtime.ts +6 -0
- package/server/lib/work-review-store.ts +2 -1
- package/server/routes/jev.ts +72 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
## 6.58.0
|
|
2
|
+
|
|
3
|
+
### Work: did the session finish the task?
|
|
4
|
+
|
|
5
|
+
- 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."
|
|
6
|
+
- COS Control 0.5.247 follows each Work handoff until the session reports on it.
|
|
7
|
+
- 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.
|
|
8
|
+
- **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.
|
|
9
|
+
- The body is exactly `{domain, id, provider, sessionId}`, plus an optional `after` (an ISO time).
|
|
10
|
+
- 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.
|
|
11
|
+
- The client never sends work or reply text.
|
|
12
|
+
- Jev answers one choice question (done, not_done or unclear) with a probability.
|
|
13
|
+
- 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"`.
|
|
14
|
+
- 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.
|
|
15
|
+
- **Answers:**
|
|
16
|
+
- `{provider: "jev", verdict, confidence, basis, model, cached}`.
|
|
17
|
+
- Otherwise `{provider: "none", reason}`:
|
|
18
|
+
- `no_reply` when there is no reply;
|
|
19
|
+
- `no_task_text` when the task has no text;
|
|
20
|
+
- any Jev condition (`jev_not_configured`, `jev_cap_reached`, `jev_breaker_open` and the rest);
|
|
21
|
+
- `completion_unavailable` for an unexpected failure, which is logged and answered 200.
|
|
22
|
+
- An unknown task is 404 `task_not_found`, an unknown session 404 `session_not_found`, and any other body 400 `invalid_completion_request`.
|
|
23
|
+
- Answers are cached for 30 minutes per task text, Done when and reply.
|
|
24
|
+
- **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.
|
|
25
|
+
- **Other changes:**
|
|
26
|
+
- `server/routes/jev.ts` finds a task through one `findTask` (Work identity first, then row id) for both suggestions and completion checks.
|
|
27
|
+
- The local Work candidate answers completion checks with no session, so it never reads this Mac's transcripts.
|
|
28
|
+
- **Tests:**
|
|
29
|
+
- The checker: policy, unrounded confidence, basis, the clipping of task, Done when and reply, cache and reasons.
|
|
30
|
+
- The replies reader, over a fixture transcript: `after` window, newest kept, unknown session.
|
|
31
|
+
- The route: board and transcripts as the only sources, `after` passed through and validated, exact bodies, 404s, a failure answered as advice.
|
|
32
|
+
- The mutation gate adds 16 mutants and runs `work-completion.test.ts`.
|
|
33
|
+
|
|
34
|
+
## 6.57.1
|
|
35
|
+
|
|
36
|
+
### Session suggestions for meeting reviews
|
|
37
|
+
|
|
38
|
+
- Miles, 2026-09-28: the meeting review "Retail Liquor Summit Campaign Launch" got no session suggestion, although a session named "Retail Liquor Summit campaign launch" existed. Suggestions only took a board task. `POST /api/work-board/session-recommendation` now also takes exactly `{reviewId, sessions}`. The server reads the review's meeting title and reviewed follow-up from its own review store; the client never supplies the work text, the same rule as for tasks. An unknown review answers 404 `review_not_found`, a server with reviews off answers 404 `reviews_unavailable`, a review request with a bad id or session list answers 400 `invalid_review_request` (its own code, so Control can tell it from a 6.57.0 server), and a mixed or unknown body answers 400. Task requests are unchanged. Needs COS Control 0.5.243 to ask for reviews.
|
|
39
|
+
- The review is read with a new `peek`, straight from the review store. The existing `get` reconciled every review row on each call, and suggestions ask on each Work visit. A lookup that throws answers 503 `review_store_unavailable` rather than "not found" (defensive: the stock lookup reads memory). `WorkReviewStore.get` now clones only the matched row.
|
|
40
|
+
- The local Work candidate passes its own review runtime the same way.
|
|
41
|
+
- Tests: the route test covers the review body, the missing review, reviews off, the exact-shape refusals and the failure fallback; the runtime test covers `peek` (no reconcile, no meeting resolution). The mutation gate adds seven mutants for the new branches (including `peek` and the review 400 code) and now runs the review runtime tests. docs/work-intake.md documents the review request.
|
|
42
|
+
|
|
1
43
|
## 6.57.0
|
|
2
44
|
|
|
3
45
|
### Jev in the server: session suggestions and a key you can set from COS Control
|
package/docs/work-intake.md
CHANGED
|
@@ -30,3 +30,7 @@ The journal is `work-intake/intake.json` in the server data directory, owned by
|
|
|
30
30
|
## Jev key and session suggestions
|
|
31
31
|
|
|
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
|
+
|
|
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.0",
|
|
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,8 +629,9 @@ 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)
|
|
633
|
-
|
|
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).
|
|
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
|
|
636
637
|
// every other settings route; the brief itself is an ordinary durable job.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'node:crypto'
|
|
9
9
|
import { estimateJevTokens, JevError, type JevClient } from './jev.js'
|
|
10
|
+
import { FORKABLE_PROVIDERS } from './agent-session-binding-store.js'
|
|
10
11
|
|
|
11
12
|
export const CONTINUE_AT = 0.5 // P(a session is already doing this exact task)
|
|
12
13
|
export const FORK_AT = 0.6 // P(some session shares the context) = 1 - P(none)
|
|
@@ -25,8 +26,9 @@ const BEST = "Which one of the user's agent sessions is the natural place to do
|
|
|
25
26
|
const EXACT = "Which one of the user's agent sessions is already doing this exact task, so the work should simply continue there? "
|
|
26
27
|
+ 'A session on the same project doing different work does not count. Choose none if no session is doing this task.'
|
|
27
28
|
|
|
28
|
-
/** Providers
|
|
29
|
-
|
|
29
|
+
/** Providers a Work handoff can fork natively (Control 0.5.243 applies a Fork suggestion as a same-platform fork).
|
|
30
|
+
* One list with the fork route (agent-session-binding-store FORKABLE_PROVIDERS). */
|
|
31
|
+
export const FORKABLE = new Set<string>(FORKABLE_PROVIDERS)
|
|
30
32
|
const clip = (text: string | undefined, max: number) => (text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
31
33
|
const round = (n: number) => Math.round(n * 100) / 100
|
|
32
34
|
|
|
@@ -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
|
+
}
|
|
@@ -174,6 +174,12 @@ export class WorkReviewRuntime {
|
|
|
174
174
|
await this.serial(async () => { if (this.stopping) return; for (const row of this.deps.store.list()) await this.reconcileRow(row, allowAdmission) })
|
|
175
175
|
}
|
|
176
176
|
async list(): Promise<WorkReview[]> { await this.reconcile(false); return this.deps.store.list().map(publicReview) }
|
|
177
|
+
/** 6.57.1: one review as stored, without reconciling every row. Session advice reads it on each Work visit.
|
|
178
|
+
* Exact id lookup only (the route validates the `wr_` pattern first). */
|
|
179
|
+
peek(id: string): WorkReview | null {
|
|
180
|
+
const row = this.deps.store.get(id)
|
|
181
|
+
return row ? publicReview(row) : null
|
|
182
|
+
}
|
|
177
183
|
async get(id: string): Promise<WorkReview> {
|
|
178
184
|
if (!/^wr_[a-f0-9]{32}$/.test(id)) throw new WorkReviewError('invalid_review_id',400)
|
|
179
185
|
await this.reconcile(false); const row = this.deps.store.get(id)
|
|
@@ -84,7 +84,8 @@ export class WorkReviewStore {
|
|
|
84
84
|
} catch { this.close(); throw new WorkReviewError('review_store_recovery_required', 503) }
|
|
85
85
|
}
|
|
86
86
|
list(): StoredWorkReview[] { return structuredClone(this.rows) }
|
|
87
|
-
|
|
87
|
+
// 6.57.1: clone the one row, not the whole journal (session advice reads a review on each Work visit).
|
|
88
|
+
get(id: string): StoredWorkReview | undefined { const row = this.rows.find(r => r.id === id); return row ? structuredClone(row) : undefined }
|
|
88
89
|
put(row: StoredWorkReview): void {
|
|
89
90
|
if (this.closed) throw new WorkReviewError('review_store_closed', 503)
|
|
90
91
|
const next = this.list(); const index = next.findIndex(r => r.id === row.id)
|
package/server/routes/jev.ts
CHANGED
|
@@ -9,22 +9,40 @@ 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
|
|
|
17
|
+
/** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
|
|
18
|
+
export interface ReviewForAdvice { source: { title: string; domain: string }; markdown?: string }
|
|
19
|
+
|
|
15
20
|
export interface JevRouteDependencies {
|
|
16
21
|
jev: JevClient
|
|
17
22
|
recommender: Pick<SessionRecommender, 'recommend'>
|
|
18
23
|
list: typeof listBoard
|
|
19
24
|
validate: typeof validateJevKey
|
|
20
25
|
save: typeof saveJevKey
|
|
26
|
+
/** Looks a meeting review up by its `wr_` id; throws or returns null when it does not exist. Absent when reviews are off. */
|
|
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)
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
|
|
24
41
|
const jev = overrides.jev ?? sharedJevClient()
|
|
25
|
-
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 }
|
|
26
44
|
const router = Router()
|
|
27
|
-
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() })
|
|
28
46
|
|
|
29
47
|
router.get('/jev-key/status', (_req, res) => res.json(deps.jev.status()))
|
|
30
48
|
|
|
@@ -53,18 +71,41 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
53
71
|
return res.json({ ok: true, ...deps.jev.status() })
|
|
54
72
|
})
|
|
55
73
|
|
|
56
|
-
/** Continue / Fork / New for one task. The
|
|
74
|
+
/** Continue / Fork / New for one task or meeting review. The work comes from the board or the review store; the
|
|
75
|
+
* client sends only candidate sessions. */
|
|
57
76
|
router.post('/work-board/session-recommendation', async (req, res) => {
|
|
58
77
|
const body = req.body
|
|
59
78
|
const sessions = parseCandidates(body?.sessions)
|
|
79
|
+
const keys = body && typeof body === 'object' ? Object.keys(body).sort().join(',') : ''
|
|
80
|
+
// 6.57.1: a meeting review is named by its id alone (`{reviewId, sessions}`), exactly.
|
|
81
|
+
if (keys === 'reviewId,sessions') {
|
|
82
|
+
if (typeof body.reviewId !== 'string' || !/^wr_[a-f0-9]{32}$/.test(body.reviewId) || !sessions) {
|
|
83
|
+
// Its own code: 6.57.0 answers a review body with invalid_recommendation_request, and Control reads THAT as an old server.
|
|
84
|
+
return res.status(400).json({ error: { code: 'invalid_review_request', message: 'Select an exact meeting review and send at most 80 sessions.' } })
|
|
85
|
+
}
|
|
86
|
+
if (!deps.review) return res.status(404).json({ error: { code: 'reviews_unavailable', message: 'Meeting reviews are not available on this server.' } })
|
|
87
|
+
let review: ReviewForAdvice | null
|
|
88
|
+
try { review = await deps.review(body.reviewId) } catch (e) { // defensive: the stock lookup reads memory and does not throw
|
|
89
|
+
console.error('[jev] review lookup failed:', e instanceof Error ? e.message : e)
|
|
90
|
+
return res.status(503).json({ error: { code: 'review_store_unavailable', message: 'Meeting reviews could not be read. Try again shortly.' } })
|
|
91
|
+
}
|
|
92
|
+
if (!review) return res.status(404).json({ error: { code: 'review_not_found', message: 'That meeting review changed or was removed. Refresh Work.' } })
|
|
93
|
+
try {
|
|
94
|
+
const title = review.source.title || 'Meeting follow-up'
|
|
95
|
+
return res.json(await deps.recommender.recommend({
|
|
96
|
+
task: { text: [title, review.markdown ?? ''].filter(Boolean).join('\n\n'), domain: review.source.domain, meetings: [title] }, sessions }))
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error('[jev] session recommendation failed:', e instanceof Error ? e.message : e)
|
|
99
|
+
return res.json({ provider: 'none', reason: 'recommendation_unavailable' })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
60
102
|
if (!body || typeof body !== 'object' || Object.keys(body).some(k => !['domain', 'id', 'sessions'].includes(k))
|
|
61
103
|
|| typeof body.domain !== 'string' || !isSafeDomainName(body.domain) || typeof body.id !== 'string' || !/^[a-f0-9]{12}$/.test(body.id) || !sessions) {
|
|
62
104
|
return res.status(400).json({ error: { code: 'invalid_recommendation_request', message: 'Select an exact task and send at most 80 sessions.' } })
|
|
63
105
|
}
|
|
64
106
|
try {
|
|
65
|
-
// Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash.
|
|
66
107
|
const rows = (await deps.list()).filter(r => r.domain === body.domain)
|
|
67
|
-
const row =
|
|
108
|
+
const row = findTask(rows, body.id)
|
|
68
109
|
if (!row) return res.status(404).json({ error: { code: 'task_not_found', message: 'That task changed or was removed. Refresh Work.' } })
|
|
69
110
|
const meetings = (row.meetingRefs ?? []).map(ref => ref.title ?? '').filter(Boolean)
|
|
70
111
|
return res.json(await deps.recommender.recommend({ task: { text: row.text || row.title || '', domain: row.domain, meetings }, sessions }))
|
|
@@ -73,5 +114,31 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
73
114
|
return res.json({ provider: 'none', reason: 'recommendation_unavailable' }) // advice only: never block the workspace
|
|
74
115
|
}
|
|
75
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
|
+
})
|
|
76
143
|
return router
|
|
77
144
|
}
|