@gotcos/glasses-server 6.81.0 → 6.82.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 +12 -0
- package/package.json +3 -1
- package/server/index.ts +7 -0
- package/server/lib/claude-bridge.ts +6 -1
- package/server/lib/codex-bridge.ts +1 -1
- package/server/lib/cursor-bridge.ts +1 -1
- package/server/lib/direct-bridge.ts +1 -1
- package/server/lib/morning-brief-schedule.ts +13 -7
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/routines-config.ts +421 -0
- package/server/lib/routines-registry.ts +151 -0
- package/server/lib/routines-runtime.ts +64 -0
- package/server/lib/routines-scheduler.ts +509 -0
- package/server/routes/routines.ts +101 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 6.82.0 - Routines: any COS skill on a schedule, each with its own cap, off switch and breaker
|
|
2
|
+
|
|
3
|
+
Miles, 2026-10-20 (plan Decision Q4): "Any COS skill on a schedule. Pick a skill, a time and days; each routine has its own cap and off switch." Until now the server had one routine, the morning brief.
|
|
4
|
+
|
|
5
|
+
- **Client matrix.** Server only in this release. Companion 6.10.650 lists, adds, edits, pauses and runs routines from the phone; older companions and COS Control keep using `/api/morning-brief`, which is unchanged. Nothing runs that a user did not create: a fresh server has the morning brief and no skill routines.
|
|
6
|
+
- **The morning brief is routine #1, unchanged.** Its config file, scheduler, prompt, scheduled submission, `/api/morning-brief` envelope, a rejected PUT and Run now while it runs are pinned by `morning-brief-golden.test.ts`, recorded on the unmodified 6.81.0 base (6b2e70b) before any routine code existed and passing byte for byte after. The only change it sees: its run is counted in the token audit as `routine:morning-brief` instead of `full_query`.
|
|
7
|
+
- **`/api/routines`.** `GET` lists the brief first, then skill routines, each with schedule, next run, gate (why it will not run), breaker state, today's runs against its cap, and its last run. `GET /routines/skills` lists this workspace's skills (`.agents/skills`, `.claude/skills`; labels only, never paths). `POST` creates; `PATCH` (or `PUT`) edits, and `resetBreaker: true` closes an open breaker; `POST /:id/pause` and `/:id/resume`; `DELETE` (the brief can only be turned off: 409 `cannot_delete_brief`); `POST /:id/run` is Run now (202; 409 running, off or skill missing; 423 breaker open, with `openUntil`; 429 capped); `GET /:id/runs` and `/routines/runs` are history. A brief edit through `/api/routines/morning-brief` takes only the brief's own fields and refuses any other (400 `not_a_brief_field`).
|
|
8
|
+
- **A skill routine.** A workspace skill, an optional extra instruction, a time and days in a zone (default the Mac's, weekdays at 08:00), runs a day (default 2, at most 10), a model (default `sonnet`, never left to the user default, never a key lane), and its breaker (default 3 failures, 6 hours). Stored in `data/routines/routines.json`, runs in `data/routines/runs.json` (0600, atomic; a damaged file keeps its good routines and drops the rest). It runs as an ordinary durable job with `origin: { kind: 'routine', id }`, so it survives the phone sleeping and lands in the inbox with a message number. Its prompt says nobody is watching: read and summarise freely, write only the skill's own files, never send, publish, deploy, push, delete or spend; if a source is missing, say so in one line.
|
|
9
|
+
- **How it stops.** Its own switch, and `COS_ROUTINES=0` turns every skill routine off (the brief keeps its own switch). Its daily cap counts scheduled and Run now runs together; a failed submit never ran and is not counted. Every skill routine together is capped at 40 runs a day. The breaker opens after N consecutive failed runs (a failed or interrupted job, or a failed submit) and nothing fires until the cooldown ends; then one trial run is allowed, a success closes it and a failure opens it again. A canceled run counts neither way, and each finished run is counted once however many ticks read it. One scheduled slot per selected day, three submit attempts two minutes apart, crash-resumed by a fixed client id. Inert while Background jobs are off or maintenance is closed, as the brief.
|
|
10
|
+
- **Token audit by caller.** A routine's run is logged as `routine:<id>` (Claude, Codex, Cursor and direct bridges; the runtime passes `auditCaller` only for routine origins, so every other caller is byte for byte what it was).
|
|
11
|
+
- **Checks.** Canary C7 (`server/scripts/routines-canary.ts`, not packaged): on a scratch data dir, a scratch HOME and workspace, an ephemeral port, and a stub `claude` on PATH, two routines fired at their slot on the production 30 s tick; the one whose skill fails exited 1, the breaker counted it, a second failure opened it, Run now answered 423 and the next tick skipped it, the other routine stayed ready, and the token audit row read `routine:<id>` on `sonnet`. Mutation gate `server/scripts/routines-mutation-gate.mjs` (not packaged): green baseline (55 tests in 6 files), 42 of 42 mutants killed, each by a named test. Full suite 8,503 passed, 0 failed, 3 skipped (562 files); `tsc --noEmit` clean. The key-lanes census classifies the three new model sites as Reject.
|
|
12
|
+
|
|
1
13
|
## 6.81.0 - The Mac saves a stopped meeting on its own once it holds every chunk
|
|
2
14
|
|
|
3
15
|
Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the meetings". Both G2 meetings that day saved only once the phone came back on screen: the phone's Stop needs IndexedDB, and iOS suspends IndexedDB while the companion is hidden (11:19 qbbgyq: Stop 12:43, `stop_blocked`, "Load failed", saved 12:47 by a new WebView; 14:07 mip9qb: Stop 15:13:50, saved 15:14:37 only after the phone was visible). The full map is `docs/meeting-autonomous-save.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.82.0",
|
|
4
4
|
"description": "COS Glasses: 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": {
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
"!server/scripts/fact-check-mutation-gate.mjs",
|
|
56
56
|
"!server/scripts/stale-session-mutation-gate.mjs",
|
|
57
57
|
"!server/scripts/autonomous-save-mutation-gate.mjs",
|
|
58
|
+
"!server/scripts/routines-mutation-gate.mjs",
|
|
59
|
+
"!server/scripts/routines-canary.ts",
|
|
58
60
|
"!server/scripts/stt-mutation-gate.mjs",
|
|
59
61
|
"!server/scripts/dictation-stt-mutation-gate.mjs",
|
|
60
62
|
"!server/scripts/pairing-mutation-gate.mjs",
|
package/server/index.ts
CHANGED
|
@@ -170,6 +170,8 @@ import { listBoard } from './lib/task-store.js'
|
|
|
170
170
|
import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
|
|
171
171
|
import { createMorningBriefRouter } from './routes/morning-brief.js'
|
|
172
172
|
import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
|
|
173
|
+
import { createRoutinesRouter } from './routes/routines.js'
|
|
174
|
+
import { getRoutineRegistry, routineSkillCatalog, startRoutineScheduler, stopRoutineScheduler } from './lib/routines-runtime.js'
|
|
173
175
|
import {
|
|
174
176
|
initQueryJobRuntime,
|
|
175
177
|
preparePublicDurableQueryAdmission,
|
|
@@ -838,6 +840,8 @@ app.use('/api', queryRouter)
|
|
|
838
840
|
// The scheduled start-of-day brief: settings, status, run-now. Same auth as
|
|
839
841
|
// every other settings route; the brief itself is an ordinary durable job.
|
|
840
842
|
app.use('/api', createMorningBriefRouter(getMorningBriefScheduler))
|
|
843
|
+
// 6.82.0: every routine (the brief first, then skill routines), each with its own cap, switch and breaker.
|
|
844
|
+
app.use('/api', createRoutinesRouter(getRoutineRegistry, routineSkillCatalog))
|
|
841
845
|
app.use('/api', providerProofRouter)
|
|
842
846
|
app.use('/api', transcribeRouter)
|
|
843
847
|
// Ported from cos-glasses-app in 6.24.0. The companion's Sessions tab has been
|
|
@@ -1257,6 +1261,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
1257
1261
|
forceExit.unref?.()
|
|
1258
1262
|
await meetingAskRuntime.shutdown()
|
|
1259
1263
|
stopMorningBriefScheduler()
|
|
1264
|
+
stopRoutineScheduler()
|
|
1260
1265
|
await workReviewRuntime?.close()
|
|
1261
1266
|
workIntakeStore?.close()
|
|
1262
1267
|
workHandoffRequestStore?.close()
|
|
@@ -1534,6 +1539,8 @@ listenRequiredServers(listeners).then(() => {
|
|
|
1534
1539
|
// that store is ready. Its own tick checks the durable-jobs switch and the
|
|
1535
1540
|
// maintenance gate, so starting it here is safe when either is off.
|
|
1536
1541
|
startMorningBriefScheduler()
|
|
1542
|
+
// 6.82.0: skill routines ride the same coordinator and the same gates.
|
|
1543
|
+
startRoutineScheduler()
|
|
1537
1544
|
void workReviewRuntime?.start().catch(error => console.error('[work-reviews] reconciliation unavailable:', error))
|
|
1538
1545
|
}).catch(error => {
|
|
1539
1546
|
// The store remains degraded and rejects admission. Legacy /api/query is
|
|
@@ -436,6 +436,11 @@ export interface CallOptions {
|
|
|
436
436
|
dispatch?: { restricted: true; tools: readonly string[] }
|
|
437
437
|
/** 6.58.2: `claude --name` for a session this COS session starts fresh (QueryJobRequest.sessionName). */
|
|
438
438
|
sessionName?: string
|
|
439
|
+
/**
|
|
440
|
+
* 6.82.0: the token-audit `caller` for this run, so a server-started job is counted by who started
|
|
441
|
+
* it (`routine:morning-brief`, `routine:routine-<id>`). Absent, every bridge writes the caller it always did.
|
|
442
|
+
*/
|
|
443
|
+
auditCaller?: string
|
|
439
444
|
/**
|
|
440
445
|
* 6.78.0 Messages steer: the job's steer port. Present only when the coordinator decided the
|
|
441
446
|
* run is steerable (lib/claude-steer.ts). Absent, every argument and every stdin byte is the
|
|
@@ -927,7 +932,7 @@ export async function callClaudeStreaming(
|
|
|
927
932
|
inputChars: inputEstimate,
|
|
928
933
|
outputChars: text.length,
|
|
929
934
|
durationMs: totalMs,
|
|
930
|
-
caller: options?.lightweight ? 'voice_query' : 'full_query',
|
|
935
|
+
caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
|
|
931
936
|
...keyAuditFields,
|
|
932
937
|
...keyUsdFields,
|
|
933
938
|
})
|
|
@@ -817,7 +817,7 @@ export async function callCodexStreaming(
|
|
|
817
817
|
inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
|
|
818
818
|
outputChars: text.length,
|
|
819
819
|
durationMs: totalMs,
|
|
820
|
-
caller: options?.lightweight ? 'voice_query' : 'full_query',
|
|
820
|
+
caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
|
|
821
821
|
...keyAuditFields,
|
|
822
822
|
...keyUsdFields,
|
|
823
823
|
})
|
|
@@ -528,7 +528,7 @@ export async function callCursorStreaming(
|
|
|
528
528
|
inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
|
|
529
529
|
outputChars: text.length,
|
|
530
530
|
durationMs: totalMs,
|
|
531
|
-
caller: options?.lightweight ? 'voice_query' : 'full_query',
|
|
531
|
+
caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
|
|
532
532
|
turnId: options?.turnId,
|
|
533
533
|
runId: run.runId,
|
|
534
534
|
sessionId: sid,
|
|
@@ -564,7 +564,7 @@ export async function callDirectStreaming(
|
|
|
564
564
|
inputChars: messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : 0), 0),
|
|
565
565
|
outputChars: text.length,
|
|
566
566
|
durationMs: Date.now() - startTime,
|
|
567
|
-
caller: 'direct_query',
|
|
567
|
+
caller: options?.auditCaller ?? 'direct_query',
|
|
568
568
|
...(options?.turnId ? { turnId: options.turnId } : {}),
|
|
569
569
|
runId: run.runId,
|
|
570
570
|
sessionId: sid,
|
|
@@ -113,9 +113,15 @@ function daysBetween(fromDay: string, toDay: string): number {
|
|
|
113
113
|
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000)
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** The schedule fields the arithmetic reads. The morning brief config and a
|
|
117
|
+
* 6.82.0 skill routine both carry them, so both share one decision table. */
|
|
118
|
+
export type ScheduleShape = Pick<MorningBriefConfig, 'enabled' | 'time' | 'timezone' | 'days' | 'catchUpMinutes'>
|
|
119
|
+
/** The ledger fields the fire decision reads (a brief run and a routine run both have them). */
|
|
120
|
+
export type ScheduledRunShape = Pick<MorningBriefRun, 'day' | 'trigger' | 'attempt' | 'firedAt' | 'jobId' | 'submitError'>
|
|
121
|
+
|
|
116
122
|
/** ISO instant of the next scheduled fire at or after `nowMs`, or null when
|
|
117
123
|
* the brief is disabled or no weekday is selected. */
|
|
118
|
-
export function nextScheduledFire(config:
|
|
124
|
+
export function nextScheduledFire(config: Pick<ScheduleShape, 'enabled' | 'time' | 'timezone' | 'days'>, nowMs: number): string | null {
|
|
119
125
|
if (!config.enabled || config.days.length === 0) return null
|
|
120
126
|
const target = parseTime(config.time)
|
|
121
127
|
const clock = localClock(nowMs, config.timezone)
|
|
@@ -129,8 +135,8 @@ export function nextScheduledFire(config: MorningBriefConfig, nowMs: number): st
|
|
|
129
135
|
return null
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
export type FireDecision =
|
|
133
|
-
| { fire: true; day: string; attempt: number; resume?:
|
|
138
|
+
export type FireDecision<R extends ScheduledRunShape = MorningBriefRun> =
|
|
139
|
+
| { fire: true; day: string; attempt: number; resume?: R }
|
|
134
140
|
| { fire: false; reason: 'disabled' | 'not_a_brief_day' | 'before_slot' | 'window_closed' | 'already_fired' | 'in_flight' | 'attempts_exhausted' | 'backoff' }
|
|
135
141
|
|
|
136
142
|
/**
|
|
@@ -140,11 +146,11 @@ export type FireDecision =
|
|
|
140
146
|
* job id and no error is a crash between ledger write and admission — resumed
|
|
141
147
|
* by client identity rather than re-run.
|
|
142
148
|
*/
|
|
143
|
-
export function decideScheduledFire(
|
|
144
|
-
config:
|
|
145
|
-
runs: readonly
|
|
149
|
+
export function decideScheduledFire<R extends ScheduledRunShape = MorningBriefRun>(
|
|
150
|
+
config: ScheduleShape,
|
|
151
|
+
runs: readonly R[],
|
|
146
152
|
nowMs: number,
|
|
147
|
-
): FireDecision {
|
|
153
|
+
): FireDecision<R> {
|
|
148
154
|
if (!config.enabled) return { fire: false, reason: 'disabled' }
|
|
149
155
|
const clock = localClock(nowMs, config.timezone)
|
|
150
156
|
if (!config.days.includes(clock.weekday)) return { fire: false, reason: 'not_a_brief_day' }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { routineAuditCaller } from './routines-config.js'
|
|
1
2
|
import { carriesBoundTo } from './agent-session-binding-store.js'
|
|
2
3
|
import { randomUUID } from 'node:crypto'
|
|
3
4
|
import { resolve } from 'node:path'
|
|
@@ -420,6 +421,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
420
421
|
sessionLockHeld: true,
|
|
421
422
|
...(request.dispatch ? { dispatch: request.dispatch } : {}),
|
|
422
423
|
...(request.sessionName ? { sessionName: request.sessionName } : {}),
|
|
424
|
+
// 6.82.0: a routine's run is counted in the token audit under the routine that started it.
|
|
425
|
+
...(routineAuditCaller(request) ? { auditCaller: routineAuditCaller(request) } : {}),
|
|
423
426
|
...(request.keyTurnOnly ? { keyTurnOnly: true } : {}),
|
|
424
427
|
// 6.78.0: only on a run the coordinator decided is steerable.
|
|
425
428
|
...(steer ? { steer } : {}),
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// 6.82.0 Routines: any COS skill on a schedule (Miles, 2026-10-20, Q4: "Pick a skill, a time
|
|
2
|
+
// and days; each routine has its own cap and off switch").
|
|
3
|
+
//
|
|
4
|
+
// The morning brief stays exactly what it was (its own config file, scheduler and golden) and is
|
|
5
|
+
// listed as routine #1 by the registry. Everything here is the SECOND kind: a skill routine.
|
|
6
|
+
//
|
|
7
|
+
// HOW A SKILL ROUTINE STOPS (every new provider caller answers this):
|
|
8
|
+
// - its own off switch (`enabled`), and the master switch COS_ROUTINES=0 for all of them;
|
|
9
|
+
// - its own daily cap: admitted runs per local day, scheduled and Run now together;
|
|
10
|
+
// - a server-wide ceiling across every skill routine per day (ROUTINE_LIMITS.globalRunsPerDay);
|
|
11
|
+
// - a breaker: N consecutive failed runs open it for a cooldown, and nothing fires while it is open;
|
|
12
|
+
// - one scheduled slot per selected day, at most `scheduledAttemptsPerDay` submit attempts;
|
|
13
|
+
// - inert while durable jobs are off or maintenance admissions are closed (as the brief).
|
|
14
|
+
// It runs on a cheap model (sonnet) unless the routine names another, and never on a key lane.
|
|
15
|
+
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
17
|
+
import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
18
|
+
import { dataPath } from './data-dir.js'
|
|
19
|
+
import { ensurePrivateDir, isValidTimezone, serverTimezone } from './morning-brief-config.js'
|
|
20
|
+
import { KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT, keyProfileRejectApplies } from './key-profile-rejects.js'
|
|
21
|
+
import {
|
|
22
|
+
normalizeEffortPreference,
|
|
23
|
+
normalizeModelPreference,
|
|
24
|
+
type EffortPreference,
|
|
25
|
+
type ModelPreference,
|
|
26
|
+
} from '../../shared/model-preference.js'
|
|
27
|
+
|
|
28
|
+
export const ROUTINES_PROTOCOL_VERSION = 1 as const
|
|
29
|
+
|
|
30
|
+
export const ROUTINE_LIMITS = Object.freeze({
|
|
31
|
+
/** Skill routines one server holds (the morning brief is not counted). */
|
|
32
|
+
maxRoutines: 12,
|
|
33
|
+
nameChars: 60,
|
|
34
|
+
instructionChars: 1_000,
|
|
35
|
+
skillNameChars: 64,
|
|
36
|
+
dailyCapDefault: 2,
|
|
37
|
+
dailyCapMin: 1,
|
|
38
|
+
dailyCapMax: 10,
|
|
39
|
+
/** Every skill routine together, per local day of the server zone. */
|
|
40
|
+
globalRunsPerDay: 40,
|
|
41
|
+
breakerFailuresDefault: 3,
|
|
42
|
+
breakerFailuresMin: 1,
|
|
43
|
+
breakerFailuresMax: 10,
|
|
44
|
+
cooldownMinutesDefault: 6 * 60,
|
|
45
|
+
cooldownMinutesMin: 15,
|
|
46
|
+
cooldownMinutesMax: 7 * 24 * 60,
|
|
47
|
+
catchUpMinutesDefault: 3 * 60,
|
|
48
|
+
catchUpMinutesMax: 12 * 60,
|
|
49
|
+
/** Submit attempts for one scheduled slot before the day is given up (as the brief). */
|
|
50
|
+
scheduledAttemptsPerDay: 3,
|
|
51
|
+
attemptSpacingMs: 2 * 60_000,
|
|
52
|
+
retainedRuns: 240,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/** Unless a routine names another model, it runs here: the server's fast default, never Opus. */
|
|
56
|
+
export const ROUTINE_DEFAULT_MODEL: ModelPreference = 'sonnet'
|
|
57
|
+
|
|
58
|
+
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|
59
|
+
const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
|
|
60
|
+
/** A skill's loader identity (`good-morning`, `customer-marketing:intel`). No slash, no path. */
|
|
61
|
+
export const ROUTINE_SKILL_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
|
62
|
+
/** Routine ids double as the job origin id (QUERY_JOB_ORIGIN_ID_RE) and never equal `morning-brief`. */
|
|
63
|
+
export const ROUTINE_ID_RE = /^routine-[0-9a-f]{10}$/
|
|
64
|
+
|
|
65
|
+
export class RoutineConfigError extends Error {
|
|
66
|
+
constructor(readonly code: string, message = code, readonly status = 400) {
|
|
67
|
+
super(message)
|
|
68
|
+
this.name = 'RoutineConfigError'
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RoutineBreakerSettings {
|
|
73
|
+
/** Consecutive failed runs that open the breaker. */
|
|
74
|
+
failures: number
|
|
75
|
+
/** How long it stays open. */
|
|
76
|
+
cooldownMinutes: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SkillRoutine {
|
|
80
|
+
id: string
|
|
81
|
+
kind: 'skill'
|
|
82
|
+
name: string
|
|
83
|
+
/** Skill loader identity, without the slash. */
|
|
84
|
+
skill: string
|
|
85
|
+
/** Optional words added after the skill call. */
|
|
86
|
+
instruction: string
|
|
87
|
+
enabled: boolean
|
|
88
|
+
time: string
|
|
89
|
+
timezone: string
|
|
90
|
+
days: number[]
|
|
91
|
+
catchUpMinutes: number
|
|
92
|
+
/** Admitted runs per local day, scheduled and Run now together. */
|
|
93
|
+
dailyCap: number
|
|
94
|
+
model: ModelPreference
|
|
95
|
+
effort?: EffortPreference
|
|
96
|
+
breaker: RoutineBreakerSettings
|
|
97
|
+
createdAt: string
|
|
98
|
+
updatedAt: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RoutineBreakerState {
|
|
102
|
+
consecutiveFailures: number
|
|
103
|
+
/** Set while the breaker is open. Nothing fires before this instant. */
|
|
104
|
+
openUntil?: string
|
|
105
|
+
openedAt?: string
|
|
106
|
+
lastFailure?: { code: string; message: string; at: string }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type RoutineTrigger = 'scheduled' | 'manual'
|
|
110
|
+
|
|
111
|
+
export interface RoutineRun {
|
|
112
|
+
id: string
|
|
113
|
+
routineId: string
|
|
114
|
+
day: string
|
|
115
|
+
trigger: RoutineTrigger
|
|
116
|
+
attempt: number
|
|
117
|
+
firedAt: string
|
|
118
|
+
clientJobId: string
|
|
119
|
+
generation: 1
|
|
120
|
+
sessionId: string
|
|
121
|
+
messageEra?: string
|
|
122
|
+
globalMsgNum?: number
|
|
123
|
+
jobId?: string
|
|
124
|
+
submitError?: { code: string; message: string }
|
|
125
|
+
lastKnownStatus?: string
|
|
126
|
+
/** Set once the run's result has been counted by the breaker (exactly once per run). */
|
|
127
|
+
outcome?: 'completed' | 'failed' | 'canceled'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface RoutinesFile {
|
|
131
|
+
v: 1
|
|
132
|
+
routines: SkillRoutine[]
|
|
133
|
+
breakers: Record<string, RoutineBreakerState>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RoutinesLedger {
|
|
137
|
+
v: 1
|
|
138
|
+
runs: RoutineRun[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface RoutineStorePaths {
|
|
142
|
+
routines: string
|
|
143
|
+
runs: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function routinePaths(root?: string): RoutineStorePaths {
|
|
147
|
+
const configured = root ?? process.env.COS_ROUTINES_DIR?.trim()
|
|
148
|
+
const base = configured ? configured : dataPath('routines')
|
|
149
|
+
return { routines: `${base}/routines.json`, runs: `${base}/runs.json` }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Master switch. Only an explicit `0`, `false`, `off` or `no` turns every skill routine off. */
|
|
153
|
+
export function routinesEnabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
154
|
+
const raw = env.COS_ROUTINES?.trim().toLowerCase()
|
|
155
|
+
return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function newRoutineId(): string {
|
|
159
|
+
return `routine-${randomBytes(5).toString('hex')}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Deterministic client id for one routine's scheduled slot, so a retry after a crashed submit
|
|
163
|
+
* admits as the SAME job (the brief's rule, keyed by routine as well). */
|
|
164
|
+
export function routineScheduledClientJobId(routineId: string, day: string, timezone: string): string {
|
|
165
|
+
const digest = createHash('sha256').update(`routine|${routineId}|${timezone}|${day}`).digest()
|
|
166
|
+
const bytes = Buffer.from(digest.subarray(0, 16))
|
|
167
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
168
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
169
|
+
const hex = bytes.toString('hex')
|
|
170
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function cleanText(value: unknown, maxChars: number): string {
|
|
174
|
+
if (typeof value !== 'string') return ''
|
|
175
|
+
return value.replace(CONTROL_RE, '').replace(/\r\n?/g, '\n').trim().slice(0, maxChars)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function intIn(raw: unknown, min: number, max: number, code: string, label: string): number {
|
|
179
|
+
const n = typeof raw === 'number' ? raw : Number(raw)
|
|
180
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) throw new RoutineConfigError(code, `${label} must be ${min} to ${max}.`)
|
|
181
|
+
return n
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeDays(raw: unknown): number[] {
|
|
185
|
+
if (!Array.isArray(raw)) throw new RoutineConfigError('invalid_days', 'days must be a list of weekday numbers, 0 (Sunday) to 6.')
|
|
186
|
+
return [...new Set(raw
|
|
187
|
+
.map(value => (typeof value === 'number' ? value : Number(value)))
|
|
188
|
+
.filter(value => Number.isInteger(value) && value >= 0 && value <= 6))]
|
|
189
|
+
.sort((a, b) => a - b)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function normalizeSkillName(raw: unknown): string {
|
|
193
|
+
const name = typeof raw === 'string' ? raw.trim().replace(/^\//, '') : ''
|
|
194
|
+
if (!ROUTINE_SKILL_RE.test(name)) throw new RoutineConfigError('invalid_skill', 'Pick a skill from this COS workspace.')
|
|
195
|
+
return name
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface RoutinePatchContext {
|
|
199
|
+
now: Date
|
|
200
|
+
/** True when the skill is in this workspace's catalog. A routine can only name one that is. */
|
|
201
|
+
skillExists: (name: string) => boolean
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create or edit a skill routine from an untrusted body. Every field is validated and an invalid
|
|
206
|
+
* one throws (a settings screen that shows Saved over a rejected time is the failure this guards).
|
|
207
|
+
* `current` absent means create: `skill` is required and everything else takes its default.
|
|
208
|
+
*/
|
|
209
|
+
export function applyRoutinePatch(current: SkillRoutine | null, raw: unknown, ctx: RoutinePatchContext): SkillRoutine {
|
|
210
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new RoutineConfigError('invalid_patch', 'Expected a JSON object.')
|
|
211
|
+
const patch = raw as Record<string, unknown>
|
|
212
|
+
const at = ctx.now.toISOString()
|
|
213
|
+
const base: SkillRoutine = current
|
|
214
|
+
? { ...current, days: [...current.days], breaker: { ...current.breaker } }
|
|
215
|
+
: {
|
|
216
|
+
id: newRoutineId(),
|
|
217
|
+
kind: 'skill',
|
|
218
|
+
name: '',
|
|
219
|
+
skill: '',
|
|
220
|
+
instruction: '',
|
|
221
|
+
enabled: true,
|
|
222
|
+
time: '08:00',
|
|
223
|
+
timezone: serverTimezone(),
|
|
224
|
+
days: [1, 2, 3, 4, 5],
|
|
225
|
+
catchUpMinutes: ROUTINE_LIMITS.catchUpMinutesDefault,
|
|
226
|
+
dailyCap: ROUTINE_LIMITS.dailyCapDefault,
|
|
227
|
+
model: ROUTINE_DEFAULT_MODEL,
|
|
228
|
+
breaker: { failures: ROUTINE_LIMITS.breakerFailuresDefault, cooldownMinutes: ROUTINE_LIMITS.cooldownMinutesDefault },
|
|
229
|
+
createdAt: at,
|
|
230
|
+
updatedAt: at,
|
|
231
|
+
}
|
|
232
|
+
if (!current && !('skill' in patch)) throw new RoutineConfigError('invalid_skill', 'Pick a skill from this COS workspace.')
|
|
233
|
+
if ('skill' in patch) {
|
|
234
|
+
const skill = normalizeSkillName(patch.skill)
|
|
235
|
+
if (!ctx.skillExists(skill)) throw new RoutineConfigError('unknown_skill', `/${skill} is not a skill in this COS workspace.`)
|
|
236
|
+
base.skill = skill
|
|
237
|
+
}
|
|
238
|
+
if ('name' in patch) {
|
|
239
|
+
if (patch.name != null && typeof patch.name !== 'string') throw new RoutineConfigError('invalid_name', 'name must be text.')
|
|
240
|
+
base.name = cleanText(patch.name, ROUTINE_LIMITS.nameChars)
|
|
241
|
+
}
|
|
242
|
+
if (!base.name) base.name = `/${base.skill}`
|
|
243
|
+
if ('instruction' in patch) {
|
|
244
|
+
if (patch.instruction != null && typeof patch.instruction !== 'string') throw new RoutineConfigError('invalid_instruction', 'instruction must be text.')
|
|
245
|
+
base.instruction = cleanText(patch.instruction, ROUTINE_LIMITS.instructionChars)
|
|
246
|
+
}
|
|
247
|
+
if ('enabled' in patch) {
|
|
248
|
+
if (typeof patch.enabled !== 'boolean') throw new RoutineConfigError('invalid_enabled', 'enabled must be true or false.')
|
|
249
|
+
base.enabled = patch.enabled
|
|
250
|
+
}
|
|
251
|
+
if ('time' in patch) {
|
|
252
|
+
const time = typeof patch.time === 'string' ? patch.time.trim() : ''
|
|
253
|
+
if (!TIME_RE.test(time)) throw new RoutineConfigError('invalid_time', 'time must be HH:MM in 24-hour form, e.g. 08:00.')
|
|
254
|
+
base.time = time
|
|
255
|
+
}
|
|
256
|
+
if ('timezone' in patch) {
|
|
257
|
+
if (!isValidTimezone(patch.timezone)) throw new RoutineConfigError('invalid_timezone', 'timezone must be an IANA zone such as America/Chicago.')
|
|
258
|
+
base.timezone = patch.timezone
|
|
259
|
+
}
|
|
260
|
+
if ('days' in patch) base.days = normalizeDays(patch.days)
|
|
261
|
+
if ('catchUpMinutes' in patch) {
|
|
262
|
+
base.catchUpMinutes = intIn(patch.catchUpMinutes, 0, ROUTINE_LIMITS.catchUpMinutesMax, 'invalid_catch_up', 'catchUpMinutes')
|
|
263
|
+
}
|
|
264
|
+
if ('dailyCap' in patch) {
|
|
265
|
+
base.dailyCap = intIn(patch.dailyCap, ROUTINE_LIMITS.dailyCapMin, ROUTINE_LIMITS.dailyCapMax, 'invalid_daily_cap', 'dailyCap')
|
|
266
|
+
}
|
|
267
|
+
if ('model' in patch) {
|
|
268
|
+
if (patch.model == null || patch.model === '') {
|
|
269
|
+
base.model = ROUTINE_DEFAULT_MODEL
|
|
270
|
+
} else {
|
|
271
|
+
const model = normalizeModelPreference(patch.model)
|
|
272
|
+
// A scheduled routine always runs on the subscription, never a key lane (Decision Q2).
|
|
273
|
+
if (!model && keyProfileRejectApplies(patch.model)) throw new RoutineConfigError(KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT)
|
|
274
|
+
if (!model) throw new RoutineConfigError('invalid_model', 'Unknown model preference.')
|
|
275
|
+
base.model = model
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if ('effort' in patch) {
|
|
279
|
+
if (patch.effort == null || patch.effort === '') {
|
|
280
|
+
delete base.effort
|
|
281
|
+
} else {
|
|
282
|
+
const effort = normalizeEffortPreference(patch.effort)
|
|
283
|
+
if (!effort) throw new RoutineConfigError('invalid_effort', 'Unknown effort preference.')
|
|
284
|
+
base.effort = effort
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if ('breaker' in patch) {
|
|
288
|
+
const b = patch.breaker
|
|
289
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) throw new RoutineConfigError('invalid_breaker', 'breaker must be an object.')
|
|
290
|
+
const rec = b as Record<string, unknown>
|
|
291
|
+
if ('failures' in rec) base.breaker.failures = intIn(rec.failures, ROUTINE_LIMITS.breakerFailuresMin, ROUTINE_LIMITS.breakerFailuresMax, 'invalid_breaker_failures', 'breaker.failures')
|
|
292
|
+
if ('cooldownMinutes' in rec) base.breaker.cooldownMinutes = intIn(rec.cooldownMinutes, ROUTINE_LIMITS.cooldownMinutesMin, ROUTINE_LIMITS.cooldownMinutesMax, 'invalid_breaker_cooldown', 'breaker.cooldownMinutes')
|
|
293
|
+
}
|
|
294
|
+
base.updatedAt = at
|
|
295
|
+
return base
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Coerce a stored routine. A damaged field takes its default; a routine with no usable id or skill is dropped. */
|
|
299
|
+
export function coerceRoutine(raw: unknown): SkillRoutine | null {
|
|
300
|
+
if (!raw || typeof raw !== 'object') return null
|
|
301
|
+
const r = raw as Record<string, unknown>
|
|
302
|
+
if (typeof r.id !== 'string' || !ROUTINE_ID_RE.test(r.id)) return null
|
|
303
|
+
if (typeof r.skill !== 'string' || !ROUTINE_SKILL_RE.test(r.skill)) return null
|
|
304
|
+
const num = (value: unknown, min: number, max: number, fallback: number) => {
|
|
305
|
+
const n = Number(value)
|
|
306
|
+
return Number.isSafeInteger(n) && n >= min && n <= max ? n : fallback
|
|
307
|
+
}
|
|
308
|
+
const breaker = (r.breaker && typeof r.breaker === 'object' ? r.breaker : {}) as Record<string, unknown>
|
|
309
|
+
const model = normalizeModelPreference(r.model)
|
|
310
|
+
const effort = normalizeEffortPreference(r.effort)
|
|
311
|
+
const now = new Date().toISOString()
|
|
312
|
+
let days: number[]
|
|
313
|
+
try { days = normalizeDays(r.days) } catch { days = [1, 2, 3, 4, 5] }
|
|
314
|
+
return {
|
|
315
|
+
id: r.id,
|
|
316
|
+
kind: 'skill',
|
|
317
|
+
name: cleanText(r.name, ROUTINE_LIMITS.nameChars) || `/${r.skill}`,
|
|
318
|
+
skill: r.skill,
|
|
319
|
+
instruction: cleanText(r.instruction, ROUTINE_LIMITS.instructionChars),
|
|
320
|
+
enabled: typeof r.enabled === 'boolean' ? r.enabled : false,
|
|
321
|
+
time: typeof r.time === 'string' && TIME_RE.test(r.time) ? r.time : '08:00',
|
|
322
|
+
timezone: isValidTimezone(r.timezone) ? r.timezone : serverTimezone(),
|
|
323
|
+
days,
|
|
324
|
+
catchUpMinutes: num(r.catchUpMinutes, 0, ROUTINE_LIMITS.catchUpMinutesMax, ROUTINE_LIMITS.catchUpMinutesDefault),
|
|
325
|
+
dailyCap: num(r.dailyCap, ROUTINE_LIMITS.dailyCapMin, ROUTINE_LIMITS.dailyCapMax, ROUTINE_LIMITS.dailyCapDefault),
|
|
326
|
+
model: model ?? ROUTINE_DEFAULT_MODEL,
|
|
327
|
+
...(effort ? { effort } : {}),
|
|
328
|
+
breaker: {
|
|
329
|
+
failures: num(breaker.failures, ROUTINE_LIMITS.breakerFailuresMin, ROUTINE_LIMITS.breakerFailuresMax, ROUTINE_LIMITS.breakerFailuresDefault),
|
|
330
|
+
cooldownMinutes: num(breaker.cooldownMinutes, ROUTINE_LIMITS.cooldownMinutesMin, ROUTINE_LIMITS.cooldownMinutesMax, ROUTINE_LIMITS.cooldownMinutesDefault),
|
|
331
|
+
},
|
|
332
|
+
createdAt: typeof r.createdAt === 'string' ? r.createdAt : now,
|
|
333
|
+
updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : now,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function coerceBreaker(raw: unknown): RoutineBreakerState {
|
|
338
|
+
const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
339
|
+
const failures = Number(r.consecutiveFailures)
|
|
340
|
+
const state: RoutineBreakerState = { consecutiveFailures: Number.isSafeInteger(failures) && failures > 0 ? failures : 0 }
|
|
341
|
+
if (typeof r.openUntil === 'string' && Number.isFinite(Date.parse(r.openUntil))) state.openUntil = r.openUntil
|
|
342
|
+
if (typeof r.openedAt === 'string') state.openedAt = r.openedAt
|
|
343
|
+
const last = r.lastFailure as Record<string, unknown> | undefined
|
|
344
|
+
if (last && typeof last.code === 'string' && typeof last.at === 'string') {
|
|
345
|
+
state.lastFailure = { code: last.code.slice(0, 64), message: String(last.message ?? '').slice(0, 300), at: last.at }
|
|
346
|
+
}
|
|
347
|
+
return state
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function loadRoutinesFile(paths: RoutineStorePaths): { file: RoutinesFile; quarantinedAs?: string } {
|
|
351
|
+
const loaded = loadJsonOrQuarantine<unknown>(paths.routines)
|
|
352
|
+
if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') {
|
|
353
|
+
return { file: { v: 1, routines: [], breakers: {} }, ...(loaded.status === 'corrupt' ? { quarantinedAs: loaded.quarantinedAs } : {}) }
|
|
354
|
+
}
|
|
355
|
+
const data = loaded.data as { routines?: unknown; breakers?: unknown }
|
|
356
|
+
const seen = new Set<string>()
|
|
357
|
+
const routines: SkillRoutine[] = []
|
|
358
|
+
for (const entry of Array.isArray(data.routines) ? data.routines : []) {
|
|
359
|
+
const routine = coerceRoutine(entry)
|
|
360
|
+
if (!routine || seen.has(routine.id)) continue
|
|
361
|
+
seen.add(routine.id)
|
|
362
|
+
routines.push(routine)
|
|
363
|
+
}
|
|
364
|
+
const breakers: Record<string, RoutineBreakerState> = {}
|
|
365
|
+
const rawBreakers = data.breakers && typeof data.breakers === 'object' ? data.breakers as Record<string, unknown> : {}
|
|
366
|
+
for (const id of seen) if (rawBreakers[id]) breakers[id] = coerceBreaker(rawBreakers[id])
|
|
367
|
+
return { file: { v: 1, routines: routines.slice(0, ROUTINE_LIMITS.maxRoutines), breakers } }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function saveRoutinesFile(paths: RoutineStorePaths, file: RoutinesFile): void {
|
|
371
|
+
ensurePrivateDir(paths.routines)
|
|
372
|
+
durableAtomicWriteFileSync(paths.routines, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isRoutineRun(value: unknown): value is RoutineRun {
|
|
376
|
+
if (!value || typeof value !== 'object') return false
|
|
377
|
+
const run = value as Record<string, unknown>
|
|
378
|
+
return typeof run.id === 'string'
|
|
379
|
+
&& typeof run.routineId === 'string'
|
|
380
|
+
&& typeof run.day === 'string'
|
|
381
|
+
&& (run.trigger === 'scheduled' || run.trigger === 'manual')
|
|
382
|
+
&& typeof run.firedAt === 'string'
|
|
383
|
+
&& typeof run.clientJobId === 'string'
|
|
384
|
+
&& typeof run.sessionId === 'string'
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function loadRoutinesLedger(paths: RoutineStorePaths): RoutinesLedger {
|
|
388
|
+
const loaded = loadJsonOrQuarantine<unknown>(paths.runs)
|
|
389
|
+
if (loaded.status !== 'ok' || !loaded.data || typeof loaded.data !== 'object') return { v: 1, runs: [] }
|
|
390
|
+
const raw = (loaded.data as { runs?: unknown }).runs
|
|
391
|
+
return { v: 1, runs: (Array.isArray(raw) ? raw : []).filter(isRoutineRun) }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function saveRoutinesLedger(paths: RoutineStorePaths, ledger: RoutinesLedger): void {
|
|
395
|
+
ensurePrivateDir(paths.runs)
|
|
396
|
+
const runs = ledger.runs.slice(-ROUTINE_LIMITS.retainedRuns)
|
|
397
|
+
durableAtomicWriteFileSync(paths.runs, `${JSON.stringify({ v: 1, runs }, null, 2)}\n`, { mode: 0o600 })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The prompt a skill routine's durable job carries. Deterministic for (routine, day) so a crash
|
|
401
|
+
* resume re-admits the same job. Unattended: it never sends, publishes or deletes. */
|
|
402
|
+
export function composeSkillRoutinePrompt(routine: Pick<SkillRoutine, 'name' | 'skill' | 'instruction'>, day: string, ownerName: string, trigger: RoutineTrigger): string {
|
|
403
|
+
const who = ownerName.trim() || 'the owner'
|
|
404
|
+
const lines = [
|
|
405
|
+
`Scheduled COS routine "${routine.name}" for ${day} (${trigger === 'manual' ? 'Run now' : 'on schedule'}).`,
|
|
406
|
+
`Run the /${routine.skill} skill from this COS workspace and give ${who} its result.`,
|
|
407
|
+
...(routine.instruction ? ['', `Also: ${routine.instruction}`] : []),
|
|
408
|
+
'',
|
|
409
|
+
'Nobody is watching this run. Read and summarise freely, and write only the files this skill keeps on this Mac.',
|
|
410
|
+
'Do not send email, Slack or any message, publish, deploy, push, delete data, or spend money. If the skill needs one of those, stop and say what it would do.',
|
|
411
|
+
'If the skill is missing or a source it needs is unavailable, say so in one plain line instead of guessing.',
|
|
412
|
+
'Answer in plain text with short lines, so it reads on the glasses.',
|
|
413
|
+
]
|
|
414
|
+
return lines.join('\n')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** 6.82.0: the token-audit caller for a job a routine started (`routine:<id>`), '' for every other
|
|
418
|
+
* job, whose callers stay exactly what they were. */
|
|
419
|
+
export function routineAuditCaller(request: { origin?: { kind: string; id: string } }): string {
|
|
420
|
+
return request.origin?.kind === 'routine' ? `routine:${request.origin.id}` : ''
|
|
421
|
+
}
|