@gotcos/glasses-server 6.82.0 → 6.84.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/docs/work-orchestrator.md +184 -0
  3. package/managed-runtime-contract.json +2 -1
  4. package/package.json +8 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/claude-bridge.ts +4 -0
  7. package/server/lib/dictation-clean.ts +26 -3
  8. package/server/lib/dictation-format.ts +119 -0
  9. package/server/lib/dictation-polish-need.ts +4 -2
  10. package/server/lib/glossary-learn.ts +88 -10
  11. package/server/lib/hallucination-filter.ts +5 -0
  12. package/server/lib/meeting-batch-transcribe.ts +12 -4
  13. package/server/lib/profile.ts +3 -1
  14. package/server/lib/query-job-coordinator.ts +5 -1
  15. package/server/lib/unsaved-audio-quarantine.ts +36 -5
  16. package/server/lib/whisper-local.ts +31 -5
  17. package/server/lib/work-chat.ts +76 -9
  18. package/server/lib/work-lens-rounds.ts +445 -0
  19. package/server/lib/work-orchestrator/discovery.ts +99 -0
  20. package/server/lib/work-orchestrator/input.ts +109 -0
  21. package/server/lib/work-orchestrator/pickup.ts +85 -0
  22. package/server/lib/work-orchestrator/plan-log.ts +38 -0
  23. package/server/lib/work-orchestrator/redact.ts +41 -0
  24. package/server/lib/work-orchestrator/runtime.ts +74 -0
  25. package/server/lib/work-orchestrator/session-log.ts +114 -0
  26. package/server/lib/work-orchestrator/snapshot.ts +87 -0
  27. package/server/lib/work-orchestrator/tag-index.ts +272 -0
  28. package/server/lib/work-orchestrator/tags.ts +52 -0
  29. package/server/lib/work-orchestrator/usage-events.ts +30 -0
  30. package/server/lib/work-orchestrator/usage.ts +341 -0
  31. package/server/routes/glossary.ts +14 -2
  32. package/server/routes/meeting.ts +76 -1
  33. package/server/routes/prompt-drafts.ts +15 -2
  34. package/server/routes/transcribe-stream.ts +127 -10
  35. package/server/routes/work-chat.ts +54 -11
  36. package/server/routes/work-lens-rounds.ts +179 -0
  37. package/server/routes/work-orchestrator.ts +71 -0
@@ -1,7 +1,7 @@
1
1
  import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
2
2
  import { getMeetingStore } from '../lib/meeting-store.js'
3
3
  import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
4
- import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
4
+ import { MAC_AUDIO, MAC_CAPTURE_ID, closeMacRecording, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
5
5
  import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
6
6
  import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
7
7
  // POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
@@ -15,7 +15,8 @@ import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSyn
15
15
  import { writeFile } from 'node:fs/promises'
16
16
  import { resolve } from 'node:path'
17
17
  import { fileURLToPath } from 'node:url'
18
- import { getVocabulary, getOwnerName, getOwnerSpeakerLabel } from '../lib/profile.js'
18
+ import { getVocabulary, getOwnerName, getOwnerSpeakerLabel, getWhisperCorrections } from '../lib/profile.js'
19
+ import { decoderContextWindow } from '../lib/glossary-learn.js'
19
20
  import { getOpenAIKey } from '../lib/openai-key.js'
20
21
  import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
21
22
  import { STRANDED_STALE_MS } from '../lib/stranded-sessions.js'
@@ -81,12 +82,14 @@ import {
81
82
  } from '../lib/even-hub-speaker-role.js'
82
83
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
83
84
  import {
85
+ DISCARDED_RETENTION_MS,
84
86
  countChunkWavs,
85
87
  listUnsavedCaptures,
86
88
  purgeExpiredQuarantine,
87
89
  quarantineSessionAudio,
88
90
  readCaptureDisposition,
89
91
  sweepOrphanedSessionAudio,
92
+ type QuarantineReceipt,
90
93
  } from '../lib/unsaved-audio-quarantine.js'
91
94
  import {
92
95
  STALE_ACTIVE_SESSION_DIR_NAME,
@@ -1728,11 +1731,14 @@ export function getMeetingSessionStatus(sessionId: string, now = Date.now()): Me
1728
1731
  }
1729
1732
  }
1730
1733
 
1734
+ /** 6.83.2: a close that quarantines under its own reason, clock and terminal receipt (the Mac discard). */
1735
+ type CloseQuarantine = { reason: string; retentionMs?: number; receipt?: QuarantineReceipt }
1736
+
1731
1737
  function closeTranscriptSession(
1732
1738
  sessionId: string,
1733
1739
  reason: ClosedTranscriptSession['reason'],
1734
- options: { preserveAudio?: boolean } = {},
1735
- ): void {
1740
+ options: { preserveAudio?: boolean; quarantine?: CloseQuarantine } = {},
1741
+ ): string | null {
1736
1742
  // Progressive HQ is disposable cache state. Every terminal session path —
1737
1743
  // save, expiry, quarantine, or abandonment — must release the single owner.
1738
1744
  void stopProgressiveHqSession(sessionId)
@@ -1751,7 +1757,7 @@ function closeTranscriptSession(
1751
1757
  maxChunkIndex,
1752
1758
  reason,
1753
1759
  })
1754
- finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
1760
+ return finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
1755
1761
  }
1756
1762
 
1757
1763
  /** Delete session after save */
@@ -1759,10 +1765,11 @@ export function deleteSession(sessionId: string, options: { preserveAudio?: bool
1759
1765
  closeTranscriptSession(sessionId, 'saved', options)
1760
1766
  }
1761
1767
 
1768
+ /** Where a close put the session's chunk audio (the quarantine dir), or null when it moved none. */
1762
1769
  function finishClosingTranscriptSession(
1763
1770
  sessionId: string,
1764
- options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason'] },
1765
- ): void {
1771
+ options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason']; quarantine?: CloseQuarantine },
1772
+ ): string | null {
1766
1773
  sessions.delete(sessionId)
1767
1774
  sessionAudioBytes.delete(sessionId)
1768
1775
  sessionAudioWrites.delete(sessionId)
@@ -1794,6 +1801,7 @@ function finishClosingTranscriptSession(
1794
1801
  if (countChunkWavs(audioDir) > 0) {
1795
1802
  const target = quarantineSessionAudio(audioDir, 'close_saved_residual_chunks')
1796
1803
  if (target) console.warn(`[transcribe-stream] Saved-close left chunk audio behind; quarantined: ${sessionId} → ${target}`)
1804
+ return target
1797
1805
  } else {
1798
1806
  try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1799
1807
  }
@@ -1801,13 +1809,114 @@ function finishClosingTranscriptSession(
1801
1809
  // Any non-saved close (expired, error, …) with chunk audio still on disk
1802
1810
  // is an unsaved capture. Quarantine it — never delete evidence (6.19.0).
1803
1811
  if (countChunkWavs(audioDir) > 0) {
1804
- const target = quarantineSessionAudio(audioDir, `close_${options.closeReason ?? 'unknown'}`)
1812
+ const quarantine = options.quarantine
1813
+ const target = quarantineSessionAudio(audioDir, quarantine?.reason ?? `close_${options.closeReason ?? 'unknown'}`, undefined,
1814
+ quarantine ? { retentionMs: quarantine.retentionMs, receipt: quarantine.receipt } : {})
1805
1815
  if (target) console.warn(`[transcribe-stream] Quarantined unsaved session-audio on close: ${sessionId} → ${target}`)
1806
1816
  else console.error(`[transcribe-stream] Quarantine move failed on close, source retained: ${sessionId}`)
1817
+ return target
1807
1818
  } else {
1808
1819
  try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1809
1820
  }
1810
1821
  }
1822
+ return null
1823
+ }
1824
+
1825
+ // ── 6.83.2: discard a stranded Mac capture that holds no sound ─────────────────────
1826
+ // Miles, 2026-10-21: Record on Mac captured a 21-minute Zoom call as 211 chunks of exact zeros (macOS returns
1827
+ // zeros, not an error, when system audio recording is not allowed). The save refused it (no_speech), and the
1828
+ // capture then sat in the stranded list with a Save that can never work. COS Control asks the server to let it go.
1829
+ //
1830
+ // What it may touch: a Mac capture (Control is its only uploader, so nothing else will send it a chunk) that is in
1831
+ // memory, holds NO canonical chunk (no transcribed text, so no speech), has finished ASR on every chunk it received
1832
+ // (an in-flight chunk could still become speech), is idle past EMPTY_SESSION_STALE_MS, and has no chunk upload in
1833
+ // progress. Anything else is refused and left exactly as it was. The audio is never deleted here: it moves into the
1834
+ // existing quarantine with a terminal no_speech receipt and a 24-hour clock (DISCARDED_RETENTION_MS).
1835
+ export type MacDiscardRefusal =
1836
+ | 'session_not_found' | 'not_mac_capture' | 'has_speech' | 'asr_in_progress' | 'not_idle' | 'chunk_in_progress'
1837
+
1838
+ export interface MacDiscardResult {
1839
+ ok: boolean
1840
+ reason?: MacDiscardRefusal
1841
+ sessionId: string
1842
+ /** The quarantine dir the audio moved into, or null when the capture held no chunk audio. */
1843
+ quarantined: string | null
1844
+ chunkFiles: number
1845
+ receivedCount: number
1846
+ retainedUntil: string | null
1847
+ }
1848
+
1849
+ export const MAC_DISCARD_IDLE_MS = EMPTY_SESSION_STALE_MS
1850
+
1851
+ /** Why a session may not be discarded, or null when it may. Pure over the session's ledger. */
1852
+ export function macDiscardRefusal(input: {
1853
+ sessionId: string
1854
+ present: boolean
1855
+ canonicalCount: number
1856
+ receivedIndices: number[]
1857
+ asrCompletedIndices: number[]
1858
+ silentForMs: number
1859
+ chunkInProgress: boolean
1860
+ }): MacDiscardRefusal | null {
1861
+ if (!MAC_CAPTURE_ID.test(input.sessionId)) return 'not_mac_capture'
1862
+ if (!input.present) return 'session_not_found'
1863
+ if (input.canonicalCount > 0) return 'has_speech'
1864
+ const completed = new Set(input.asrCompletedIndices)
1865
+ if (input.receivedIndices.some(index => !completed.has(index))) return 'asr_in_progress'
1866
+ if (input.chunkInProgress) return 'chunk_in_progress'
1867
+ if (input.silentForMs < MAC_DISCARD_IDLE_MS) return 'not_idle'
1868
+ return null
1869
+ }
1870
+
1871
+ export async function discardSilentMacCapture(sessionId: string, options: { now?: number; dryRun?: boolean } = {}): Promise<MacDiscardResult> {
1872
+ const now = options.now ?? Date.now()
1873
+ const refusalFor = () => {
1874
+ const session = sessions.get(sessionId)
1875
+ return {
1876
+ session,
1877
+ refusal: macDiscardRefusal({
1878
+ sessionId,
1879
+ present: !!session,
1880
+ canonicalCount: session ? canonicalIndices(session).length : 0,
1881
+ receivedIndices: session?.receivedIndices ?? [],
1882
+ asrCompletedIndices: session?.asrCompletedIndices ?? [],
1883
+ silentForMs: session ? now - session.lastActivityAt : 0,
1884
+ chunkInProgress: macChunkLocks.has(sessionId),
1885
+ }),
1886
+ }
1887
+ }
1888
+ const empty = (reason: MacDiscardRefusal, receivedCount = 0): MacDiscardResult =>
1889
+ ({ ok: false, reason, sessionId, quarantined: null, chunkFiles: 0, receivedCount, retainedUntil: null })
1890
+ const first = refusalFor()
1891
+ if (first.refusal) return empty(first.refusal, first.session?.receivedIndices?.length ?? 0)
1892
+ if (options.dryRun) {
1893
+ const audioDir = getSessionAudioDirectory(sessionId)
1894
+ return { ok: true, sessionId, quarantined: null, chunkFiles: audioDir ? countChunkWavs(audioDir) : 0,
1895
+ receivedCount: first.session?.receivedIndices?.length ?? 0, retainedUntil: null }
1896
+ }
1897
+ // Every chunk write this session started reaches disk before its directory moves.
1898
+ await drainSessionAudioWrites(sessionId)
1899
+ // Ask again after the await: a chunk may have arrived meanwhile.
1900
+ const second = refusalFor()
1901
+ if (second.refusal) return empty(second.refusal, second.session?.receivedIndices?.length ?? 0)
1902
+ const receivedCount = second.session?.receivedIndices?.length ?? 0
1903
+ const quarantined = closeTranscriptSession(sessionId, 'closed', {
1904
+ quarantine: {
1905
+ reason: 'discarded_no_sound',
1906
+ retentionMs: DISCARDED_RETENTION_MS,
1907
+ receipt: { outcome: 'no_speech', discarded: true, note: 'Discarded from COS Control: no sound was captured. Audio retained until its 24-hour clock clears it.' },
1908
+ },
1909
+ })
1910
+ // A late chunk or a replayed start for this capture answers 410 from here on.
1911
+ closeMacRecording(sessionId)
1912
+ return {
1913
+ ok: true,
1914
+ sessionId,
1915
+ quarantined,
1916
+ chunkFiles: quarantined ? countChunkWavs(quarantined) : 0,
1917
+ receivedCount,
1918
+ retainedUntil: quarantined ? new Date(Date.now() + DISCARDED_RETENTION_MS).toISOString() : null,
1919
+ }
1811
1920
  }
1812
1921
 
1813
1922
  function isIosAsrCandidateEnabled(): boolean {
@@ -1946,7 +2055,13 @@ function recentWhisperContext(sessionId: string, session: TranscriptSession): st
1946
2055
  .map(c => c.text)
1947
2056
  .join(' ')
1948
2057
  const cleanedContext = stripInlineHallucinations(recentText, sessionId)
1949
- return cleanedContext.length > 250 ? cleanedContext.slice(-250) : cleanedContext
2058
+ // 6.83.1: correction-only forms ("/pricing") go back to speech BEFORE the 250-character cut.
2059
+ return decoderContextWindow(cleanedContext, decoderCorrections()) ?? ''
2060
+ }
2061
+
2062
+ /** The corrections map for the decoder window; a test double without it reads as none. */
2063
+ function decoderCorrections(): Record<string, string> {
2064
+ try { return getWhisperCorrections() } catch { return {} }
1950
2065
  }
1951
2066
 
1952
2067
  function isCrossChunkRepeat(session: TranscriptSession, text: string): boolean {
@@ -2610,7 +2725,9 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
2610
2725
 
2611
2726
  // Manual Mac pilot: independent from iPhone candidate and cue rollout gates.
2612
2727
  transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
2613
- res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId() })
2728
+ // 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
2729
+ // servers omit it, and COS Control then discards only its own local copy and says so.
2730
+ res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
2614
2731
  })
2615
2732
  function requireMacPin(req: { get(name: string): string | undefined }): string {
2616
2733
  const pin = req.get('X-COS-Server-Instance')?.trim()
@@ -1,33 +1,76 @@
1
1
  import { Router } from 'express'
2
- import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
3
- WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES } from '../lib/work-chat.js'
2
+ import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_ORCHESTRATOR_RULES, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
3
+ WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES,
4
+ WORK_CHAT_BODY_BYTES, WORK_CHAT_ORCHESTRATOR_BODY_BYTES, type WorkChatInput, type WorkChatAnswer } from '../lib/work-chat.js'
4
5
  import { runMeetingAskModel } from '../lib/meeting-ask-runner.js'
5
6
  import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
6
- export function createWorkChatRouter(deps = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat')}) {
7
+ import { appendPlanLog, type PlanLogRow } from '../lib/work-orchestrator/plan-log.js'
8
+ import { PLAN_LOG_PATH } from '../lib/work-orchestrator/runtime.js'
9
+ import { defaultLensRoundStore } from '../lib/work-lens-rounds.js'
10
+
11
+ /** 6.83.0: one plan's log row. Ids and enums only (plan-log.ts). */
12
+ export function planLogRow(input: WorkChatInput, outcome: PlanLogRow['outcome'], answer: WorkChatAnswer | null, ms: number, now = new Date()): PlanLogRow {
13
+ return {
14
+ at: now.toISOString(), workID: input.workID, mode: input.mode, planner: input.planner ?? 'sonnet', effort: input.effort ?? WORK_CHAT_DEFAULT_EFFORT,
15
+ orchestrator: input.orchestrator === true,
16
+ sessions: input.sessions.map(s => ({ id: s.id, automatic: s.automatic, ...(s.available !== undefined ? { available: s.available } : {}),
17
+ ...(s.reason ? { reason: s.reason } : {}), ...(s.discoveredBy ? { discoveredBy: s.discoveredBy } : {}) })),
18
+ leftOut: (input.leftOut ?? []).map(l => ({ id: l.id, reason: l.reason })),
19
+ usageAgeSec: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.ageSec])),
20
+ usageState: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.state])),
21
+ assignments: (answer?.assignments ?? []).map(a => ({ mode: a.mode ?? 'continue', target: a.mode === 'newSession' ? `new:${a.provider}` : a.sessionID ?? '' })),
22
+ outcome, ms,
23
+ }
24
+ }
25
+
26
+ const WORK_CHAT_DEPS = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat'),
27
+ planLog:(row: PlanLogRow)=>appendPlanLog(PLAN_LOG_PATH(), row), now:()=>Date.now(),
28
+ lensPlansControl:()=>defaultLensRoundStore().controlStatus()}
29
+ export function createWorkChatRouter(given: Partial<typeof WORK_CHAT_DEPS> = {}) {
30
+ // 6.83.0: a caller that passes only some deps (the tests pass run, admissions and lease) gets the rest by default.
31
+ const deps={...WORK_CHAT_DEPS,...given}
7
32
  const router=Router(); let running=false
8
33
  router.use('/work/chat', (_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
9
34
  // 6.73.15: queueRounds means sessions may carry openWork, and the planner routes around sessions with earlier work open.
10
35
  // references means the input may carry read-only reference sessions (never targets).
11
- router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',planners:WORK_CHAT_PLANNERS,effort:WORK_CHAT_DEFAULT_EFFORT,efforts:WORK_CHAT_EFFORTS,automaticProviders:['claude','codex'],maxAssignments:WORK_CHAT_MAX_ASSIGNMENTS,contextItems:true,maxContextItems:WORK_CHAT_MAX_ITEMS,queueRounds:true,references:true,maxReferences:WORK_CHAT_MAX_REFERENCES}))
36
+ // 6.83.0: orchestrator 1 means a client may send the orchestrator input (POST /api/work/orchestrator/input) and get
37
+ // fork and new-session assignments back (docs/work-orchestrator.md).
38
+ // 6.83.0: lensPlans 1 means the lens may record a plan into the card's Work chat history and ask COS Control to send
39
+ // it (routes/work-lens-rounds.ts); lensPlansControl says whether a COS Control polled the lens inbox in the last 60 s.
40
+ router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',planners:WORK_CHAT_PLANNERS,effort:WORK_CHAT_DEFAULT_EFFORT,efforts:WORK_CHAT_EFFORTS,automaticProviders:['claude','codex'],maxAssignments:WORK_CHAT_MAX_ASSIGNMENTS,contextItems:true,maxContextItems:WORK_CHAT_MAX_ITEMS,queueRounds:true,references:true,maxReferences:WORK_CHAT_MAX_REFERENCES,orchestrator:1,
41
+ lensPlans:1,lensPlansControl:deps.lensPlansControl()}))
12
42
  router.post('/work/chat',async(req,res)=>{
13
- let input
14
- try { if (Buffer.byteLength(JSON.stringify(req.body))>100000) throw new Error(); input=parseWorkChatInput(req.body) }
43
+ let input: WorkChatInput
44
+ try {
45
+ const limit=req.body?.orchestrator===true ? WORK_CHAT_ORCHESTRATOR_BODY_BYTES : WORK_CHAT_BODY_BYTES
46
+ if (Buffer.byteLength(JSON.stringify(req.body))>limit) throw new Error(); input=parseWorkChatInput(req.body)
47
+ }
15
48
  catch {return res.status(400).json({error:'invalid_work_chat'})}
16
49
  if (running || !deps.admissions()) return res.status(409).json({error:'work_chat_busy'})
17
50
  running=true
51
+ const started=deps.now()
18
52
  let lease: ReturnType<typeof acquireMaintenanceWork> | undefined
53
+ let outcome: PlanLogRow['outcome']='unavailable', answer: WorkChatAnswer | null=null
19
54
  const controller=new AbortController(); const abort=()=>controller.abort()
20
55
  res.on('close',abort)
21
56
  try {
22
57
  lease=deps.lease()
23
58
  // Planner and effort select how the model runs; they are not evidence, so they never reach the prompt.
24
59
  const {planner='sonnet',effort=WORK_CHAT_DEFAULT_EFFORT,...prompt}=input
25
- const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),WORK_CHAT_SYSTEM,controller.signal,undefined,{caller:'work_chat_'+input.mode},
26
- {effort,maxOutputTokens:WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
27
- const answer=parseWorkChatAnswer(out.text,input)
60
+ const orchestrator=input.orchestrator===true
61
+ const system=orchestrator ? WORK_CHAT_SYSTEM+'\n\n'+WORK_CHAT_ORCHESTRATOR_RULES : WORK_CHAT_SYSTEM
62
+ const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),system,controller.signal,undefined,{caller:'work_chat_'+input.mode},
63
+ {effort,maxOutputTokens:orchestrator ? WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS : WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
64
+ try { answer=parseWorkChatAnswer(out.text,input) } catch (e) { outcome='invalid_answer'; throw e }
65
+ outcome='planned'
28
66
  if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??`claude:${planner} (requested)`,effort})
29
- } catch {if(!res.destroyed) return res.status(503).json({error:'work_chat_unavailable',message:'No assignment was dispatched. Your saved request can be retried.'})}
30
- finally {res.off('close',abort);lease?.release();running=false}
67
+ outcome='aborted'
68
+ } catch {if (controller.signal.aborted && outcome==='unavailable') outcome='aborted'; if(!res.destroyed) return res.status(503).json({error:'work_chat_unavailable',message:'No assignment was dispatched. Your saved request can be retried.'})}
69
+ finally {
70
+ res.off('close',abort);lease?.release();running=false
71
+ // 6.83.0: every plan is logged, whatever its outcome (plan-log.ts); a log failure never changes the answer.
72
+ try { deps.planLog(planLogRow(input,outcome,answer,deps.now()-started)) } catch { /* observation only */ }
73
+ }
31
74
  })
32
75
  return router
33
76
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * 6.83.0: lens plans in Work chat history, and "Send this plan" (docs/work-orchestrator.md, "Lens plans").
3
+ *
4
+ * Lens (over the network, token auth from the parent /api mount):
5
+ * POST /api/work/chat/lens-rounds record a plan the lens made (idempotent on clientRoundId)
6
+ * GET /api/work/chat/lens-rounds/:roundId the round's live state, never its prompts
7
+ * POST /api/work/chat/lens-rounds/:roundId/send ask COS Control to send it (two-step confirm, or card Auto-send)
8
+ * POST /api/work/chat/lens-rounds/:roundId/stop ask COS Control to Stop assignments
9
+ * COS Control (this Mac only):
10
+ * GET /api/work/chat/lens-inbox?since=<cursor>[&autoSend=1] rounds not yet in history, send and stop requests
11
+ * POST /api/work/chat/lens-rounds/:roundId/history { state: "in_history", autoSendCard? }
12
+ * POST /api/work/chat/lens-send/:sendRequestId/result { assignments: [{ id, state, receiptId?, detail?, sessionID? }] }
13
+ * POST /api/work/chat/lens-rounds/:roundId/mac-send the same shape, for COS Control's own Send of this round on the Mac
14
+ *
15
+ * Nothing here sends to a session: COS Control performs every send through its own Send path and reports back.
16
+ * Every route answers 503 `maintenance` while admissions are closed; writes hold the api_mutation lease themselves
17
+ * (index.ts leaves these paths to this router so the error code is the contract's).
18
+ */
19
+ import { Router, type Request, type Response } from 'express'
20
+ import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
21
+ parseSendBody, parseStopBody, roundView, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
22
+ import type { ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
23
+ import { orchestratorSnapshots } from '../lib/work-orchestrator/runtime.js'
24
+ import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
25
+ import { isLoopbackRequest } from './work-handoff-requests.js'
26
+ import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
27
+ import { createWorkBoardReader, type WorkBoardReader } from '../lib/work-board-reader.js'
28
+
29
+ export interface WorkLensRoundsDeps {
30
+ store: LensRoundStore
31
+ /** COS Control's Work snapshot (links and receipts), for the card's sessions. */
32
+ snapshot: () => ControlSnapshot | null
33
+ /** Whether the workID is a card the server knows: in COS Control's snapshot or on the Work board. Throws when unknowable. */
34
+ knownWork: (workID: string) => Promise<boolean>
35
+ admissions: () => boolean
36
+ lease: () => { release(): void }
37
+ isLocal: (req: Request) => boolean
38
+ }
39
+
40
+ /** A card is known when COS Control's snapshot names it, or the Work board (a bounded read, 2 s old at most) lists it. */
41
+ export function knownWorkFrom(snapshot: () => ControlSnapshot | null, board: WorkBoardReader<TaskBoardRow>) {
42
+ return async (workID: string): Promise<boolean> => {
43
+ const s = snapshot()
44
+ if (s && (s.links.some(l => l.workID === workID) || s.receipts.some(r => r.workID === workID))) return true
45
+ const rows = await board.read(2_000)
46
+ return rows.some(r => `task:${r.domain}:${r.workIdentity || r.id}` === workID)
47
+ }
48
+ }
49
+
50
+ let defaultBoard: WorkBoardReader<TaskBoardRow> | null = null
51
+ const DEFAULTS = (): WorkLensRoundsDeps => ({
52
+ store: defaultLensRoundStore(),
53
+ snapshot: () => orchestratorSnapshots().get(),
54
+ knownWork: knownWorkFrom(() => orchestratorSnapshots().get(), defaultBoard ??= createWorkBoardReader(() => listBoard())),
55
+ admissions: maintenanceAdmissionsOpen,
56
+ lease: () => acquireMaintenanceWork('api_mutation'),
57
+ isLocal: isLoopbackRequest,
58
+ })
59
+
60
+ const MAINTENANCE = { error: 'maintenance', message: 'The server is updating. The plan is kept on the lens; try again shortly.' }
61
+
62
+ export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> = {}) {
63
+ const deps: WorkLensRoundsDeps = { ...DEFAULTS(), ...given }
64
+ const router = Router()
65
+ router.use(['/work/chat/lens-rounds', '/work/chat/lens-inbox', '/work/chat/lens-send'], (_req, res, next) => {
66
+ res.set('Cache-Control', 'private, no-store')
67
+ if (!deps.admissions()) return res.status(503).json(MAINTENANCE)
68
+ next()
69
+ })
70
+ const fail = (res: Response, e: unknown, where: string) => {
71
+ if (e instanceof LensRoundError) {
72
+ if (e.status >= 500 || where !== 'record') console.warn(`[work-lens-rounds] ${where}: ${e.status} ${e.code}`)
73
+ return res.status(e.status).json({ error: e.code, message: e.message, ...e.extra })
74
+ }
75
+ console.error(`[work-lens-rounds] ${where} failed: ${e instanceof Error ? e.message : String(e)}`)
76
+ return res.status(503).json({ error: 'lens_rounds_unavailable', message: 'Lens plans are unavailable. Try again shortly.' })
77
+ }
78
+ /** A write holds the api_mutation lease for its own (sub-millisecond) body; a closed gate is `maintenance`. */
79
+ const write = <T>(res: Response, where: string, fn: () => Promise<T> | T) => {
80
+ let lease: { release(): void }
81
+ try { lease = deps.lease() } catch { return res.status(503).json(MAINTENANCE) }
82
+ return Promise.resolve().then(fn).catch(e => fail(res, e, where)).finally(() => lease.release())
83
+ }
84
+ const local = (req: Request) => { if (!deps.isLocal(req)) throw new LensRoundError('local_only', 403, 'Only COS Control on this Mac can do this.') }
85
+ const roundIdOf = (req: Request) => {
86
+ const id = String(req.params.roundId ?? '')
87
+ if (!ROUND_ID.test(id)) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
88
+ return id
89
+ }
90
+ const connected = () => deps.store.controlStatus().connected
91
+
92
+ router.post('/work/chat/lens-rounds', (req, res) => write(res, 'record', async () => {
93
+ if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > LENS_ROUND_BODY_BYTES) {
94
+ return res.status(413).json({ error: 'lens_round_too_large', message: 'This plan is too large to save.' })
95
+ }
96
+ const input = parseLensRound(req.body)
97
+ // A repeat is answered from the store, even when the board has since changed or is unreachable.
98
+ const replay = deps.store.replay(input.clientRoundId)
99
+ if (!replay) {
100
+ let known: boolean
101
+ try { known = await deps.knownWork(input.workID) }
102
+ catch { throw new LensRoundError('work_board_unavailable', 503, 'The Work board is unavailable. The plan is kept on the lens; try again shortly.') }
103
+ if (!known) throw new LensRoundError('unknown_work', 404, 'This card is not on the Work board any more.')
104
+ }
105
+ const { round, created } = deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))
106
+ if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}`)
107
+ return res.status(created ? 201 : 200).json({ roundId: round.roundId, workID: round.workID, state: roundView(round).state, control: { connected: connected() } })
108
+ }))
109
+
110
+ router.get('/work/chat/lens-rounds/:roundId', (req, res) => {
111
+ try {
112
+ const round = deps.store.find(roundIdOf(req))
113
+ if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
114
+ return res.json(roundView(round))
115
+ } catch (e) { return fail(res, e, 'status') }
116
+ })
117
+
118
+ router.post('/work/chat/lens-rounds/:roundId/send', (req, res) => write(res, 'send', () => {
119
+ const roundId = roundIdOf(req)
120
+ const body = parseSendBody(req.body)
121
+ const round = deps.store.find(roundId)
122
+ if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
123
+ const send = deps.store.requestSend(roundId, body, { cardSessions: cardSessionsOf(deps.snapshot(), round.workID), controlConnected: connected() })
124
+ console.log(`[work-lens-rounds] send requested ${send.id} round=${roundId} assignments=${send.assignmentIds.join(',')} confirm=${send.confirm ?? 'auto-send'}`)
125
+ return res.status(202).json({ sendRequestId: send.id, state: 'send_requested' })
126
+ }))
127
+
128
+ router.post('/work/chat/lens-rounds/:roundId/stop', (req, res) => write(res, 'stop', () => {
129
+ const roundId = roundIdOf(req)
130
+ const stop = deps.store.requestStop(roundId, parseStopBody(req.body), connected())
131
+ console.log(`[work-lens-rounds] stop requested ${stop.id} round=${roundId} assignments=${stop.assignmentIds.join(',')}`)
132
+ return res.status(202).json({ stopRequestId: stop.id, state: 'stop_requested' })
133
+ }))
134
+
135
+ // COS Control: polling this is what the capability reports as connected. Listing a send or a stop picks it up.
136
+ router.get('/work/chat/lens-inbox', (req, res) => {
137
+ try {
138
+ local(req)
139
+ const { since, autoSend } = req.query
140
+ if (Object.keys(req.query).some(k => k !== 'since' && k !== 'autoSend')
141
+ || (since !== undefined && !(typeof since === 'string' && /^\d{1,12}$/.test(since)))
142
+ || (autoSend !== undefined && autoSend !== '0' && autoSend !== '1')) {
143
+ throw new LensRoundError('invalid_lens_inbox', 400, 'since is a cursor and autoSend is 0 or 1.')
144
+ }
145
+ return res.json(deps.store.inbox(since === undefined ? 0 : Number(since), autoSend === '1'))
146
+ } catch (e) { return fail(res, e, 'inbox') }
147
+ })
148
+
149
+ router.post('/work/chat/lens-rounds/:roundId/history', (req, res) => write(res, 'history', () => {
150
+ local(req)
151
+ const roundId = roundIdOf(req)
152
+ const b = req.body
153
+ if (!b || typeof b !== 'object' || Array.isArray(b) || b.state !== 'in_history' || (b.autoSendCard !== undefined && typeof b.autoSendCard !== 'boolean')) {
154
+ throw new LensRoundError('invalid_lens_round', 400, 'History takes { state: "in_history" }.', { field: 'state' })
155
+ }
156
+ const round = deps.store.markInHistory(roundId, b.autoSendCard)
157
+ return res.json(roundView(round))
158
+ }))
159
+
160
+ // COS Control: it sent assignments of this round with its own Send on the Mac. Same assignments[] shape as a result.
161
+ router.post('/work/chat/lens-rounds/:roundId/mac-send', (req, res) => write(res, 'mac-send', () => {
162
+ local(req)
163
+ const round = deps.store.reportMac(roundIdOf(req), parseResultBody(req.body))
164
+ const view = roundView(round)
165
+ console.log(`[work-lens-rounds] mac send round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
166
+ return res.json(view)
167
+ }))
168
+
169
+ router.post('/work/chat/lens-send/:sendRequestId/result', (req, res) => write(res, 'result', () => {
170
+ local(req)
171
+ const id = String(req.params.sendRequestId ?? '')
172
+ if (!SEND_ID.test(id)) throw new LensRoundError('unknown_send', 404, 'No such send request.')
173
+ const round = deps.store.report(id, parseResultBody(req.body))
174
+ const view = roundView(round)
175
+ console.log(`[work-lens-rounds] result ${id} round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
176
+ return res.json(view)
177
+ }))
178
+ return router
179
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 6.83.0 Work orchestrator routes (docs/work-orchestrator.md). Read and plan inputs only: nothing here sends to a
3
+ * session, changes a link or a receipt, or calls a model.
4
+ *
5
+ * POST /api/work/orchestrator/input {workID, meetingWorkIDs?, snapshot?}
6
+ * Stores Control's snapshot when one is sent (Phase A: Control pushes, the server keeps the copy), then answers
7
+ * the card's planner rows, what was left out and why, and the usage snapshot.
8
+ * GET /api/work/orchestrator/usage[?refresh=1]
9
+ * The usage snapshot per provider, read on demand: Claude and Codex are re-read when their reading is older than
10
+ * 10 minutes (`refresh=1`: 2 minutes). No timer reads usage unless COS_WORK_USAGE_POLL=1.
11
+ */
12
+ import { Router } from 'express'
13
+ import { parseSnapshot, type ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
14
+ import type { OrchestratorInput } from '../lib/work-orchestrator/input.js'
15
+ import type { UsageMonitor, UsageView } from '../lib/work-orchestrator/usage.js'
16
+ import { orchestratorInputFor, orchestratorSnapshots, orchestratorUsage } from '../lib/work-orchestrator/runtime.js'
17
+
18
+ const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
19
+ const MEETING_ID = /^meeting:[^\u0000-\u001f]{1,250}$/
20
+ export const ORCHESTRATOR_BODY_BYTES = 4_000_000
21
+
22
+ export interface WorkOrchestratorDeps {
23
+ putSnapshot: (s: ControlSnapshot) => void
24
+ input: (workID: string, meetingWorkIDs: string[]) => Promise<OrchestratorInput>
25
+ usage: (refresh: boolean) => Promise<UsageView[]>
26
+ now: () => number
27
+ }
28
+
29
+ /** GET /usage reads on demand: a reading older than 10 minutes is re-read (`refresh=1`: 2 minutes). */
30
+ export const readUsage = (refresh: boolean, monitor: UsageMonitor = orchestratorUsage()): Promise<UsageView[]> => monitor.onDemand(refresh)
31
+
32
+ const DEFAULTS: WorkOrchestratorDeps = {
33
+ putSnapshot: s => orchestratorSnapshots().put(s),
34
+ input: orchestratorInputFor,
35
+ usage: refresh => readUsage(refresh),
36
+ now: () => Date.now(),
37
+ }
38
+
39
+ export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps> = {}) {
40
+ const deps = { ...DEFAULTS, ...given }
41
+ const router = Router()
42
+ router.use('/work/orchestrator', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
43
+ router.post('/work/orchestrator/input', async (req, res) => {
44
+ const body = req.body
45
+ const meetings: unknown = body?.meetingWorkIDs ?? []
46
+ if (!body || typeof body !== 'object' || Object.keys(body).some(k => !['workID', 'meetingWorkIDs', 'snapshot'].includes(k))
47
+ || typeof body.workID !== 'string' || !WORK_ID.test(body.workID)
48
+ || !Array.isArray(meetings) || meetings.length > 20 || !meetings.every(m => typeof m === 'string' && MEETING_ID.test(m))) {
49
+ return res.status(400).json({ error: 'invalid_orchestrator_input' })
50
+ }
51
+ if ('snapshot' in body) {
52
+ if (Buffer.byteLength(JSON.stringify(body.snapshot)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
53
+ let snapshot: ControlSnapshot
54
+ try { snapshot = parseSnapshot(body.snapshot, deps.now()) } catch (e) { return res.status(400).json({ error: 'invalid_snapshot', reason: (e as Error).message }) }
55
+ deps.putSnapshot(snapshot)
56
+ }
57
+ try {
58
+ const input = await deps.input(body.workID, meetings as string[])
59
+ console.log(`[work-orchestrator] input work=${body.workID} rows=${input.rows.length} discovered=${input.discovered} leftOut=${input.leftOut.length} snapshotAge=${input.snapshotAgeSec ?? '-'}s liveAge=${input.liveListAgeSec ?? '-'}s scan=${input.logScanComplete ? 'complete' : 'partial'}`)
60
+ return res.json(input)
61
+ } catch (e) {
62
+ console.warn(`[work-orchestrator] input failed for ${body.workID}: ${(e as Error).message}`)
63
+ return res.status(503).json({ error: 'orchestrator_input_unavailable' })
64
+ }
65
+ })
66
+ router.get('/work/orchestrator/usage', async (req, res) => {
67
+ try { return res.json({ protocolVersion: 1, now: new Date(deps.now()).toISOString(), usage: await deps.usage(req.query.refresh === '1') }) }
68
+ catch { return res.status(503).json({ error: 'usage_unavailable' }) }
69
+ })
70
+ return router
71
+ }