@gotcos/glasses-server 6.57.1 → 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 CHANGED
@@ -1,3 +1,36 @@
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
+
1
34
  ## 6.57.1
2
35
 
3
36
  ### Session suggestions for meeting reviews
@@ -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.57.1",
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,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) and Continue/Fork/New session recommendations for Work tasks (6.57.0) and meeting reviews (6.57.1).
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
@@ -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
+ }
@@ -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, ...overrides }
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 = rows.find(r => r.workIdentity === body.id) ?? rows.find(r => r.id === body.id)
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
  }