diff --git a/.env.example b/.env.example index 33d231f..89ba161 100644 --- a/.env.example +++ b/.env.example @@ -31,11 +31,15 @@ NOTION_PARENT_PAGE_ID= AGENT_NAME=Coach TIMEZONE=Europe/London -# Model ids. Sonnet is a good default; the fast model is used for trivial -# logging messages to save on usage. +# Model ids. CLAUDE_MODEL handles everything by default; set it to +# claude-opus-4-8 for Opus. The fast model is only used when FAST_TIER_ENABLED. CLAUDE_MODEL=claude-sonnet-4-6 CLAUDE_MODEL_FAST=claude-haiku-4-5 +# Route short, transactional logging messages to CLAUDE_MODEL_FAST to save usage. +# Off by default so behaviour is consistent (every message uses CLAUDE_MODEL). +FAST_TIER_ENABLED=false + # Reasoning effort for the standard model: low | medium | high | xhigh | max. # Lower is faster and cheaper with less deep thinking. Default medium. REASONING_EFFORT=medium diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b4e1c2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run build + - run: npm test diff --git a/CLAUDE.md b/CLAUDE.md index ec73830..2ba3cc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,12 +85,10 @@ All Notion work goes through the bundled helper `scripts/notion.mjs` (run via Ba The Dashboard page is a live snapshot and summary of what is happening: current program and week, active goals, this week's training, latest body stats, and the next session. It also doubles as the central index of the workspace: keep a small, tasteful set of links near the top to the key pages and databases (Programs, Goals, Body Stats, Workout Log, Knowledge Base) so the user can jump anywhere from one place. Keep that index compact (a single linked row or a small callout), never a cluttered wall of links. Treat the whole page as something you keep in sync, not a one-off build. Whenever you change the underlying data, refresh the affected tile in the same turn, before you reply, so the Dashboard never goes stale: -- Logged a session: refresh the `thisWeek` tile (re-query the Workout Log for the current week: sessions done, last session, next session). -- Added or changed a goal: refresh the `goals` tile (re-query the Goals database). -- Logged body weight or measurements: refresh the `bodyStats` tile. +- Logged a session, changed a goal, or logged body weight or measurements: run `node scripts/notion.mjs sync-dashboard` once. It re-derives the `thisWeek`, `goals`, and `bodyStats` tiles straight from the databases (counts and values come from queries, never hand-typed), so it is the preferred, fabrication-proof way to keep those three tiles current. Pass `--now ` (the date from the Telegram header) so "this week" is correct in the user's timezone. - Changed the program, started a new week, or anything that touches the hero (program, week, dates): rebuild the relevant tile, or rebuild the whole Dashboard if several tiles are affected at once (`node scripts/setup-workspace.mjs --rebuild-dashboard`). -Use `scripts/notion.mjs refresh-tile --tile ` with tile ids resolved from `data/notion-ids.json` (`__dashboard.columns`). Never edit blocks in place; replace the whole tile. The notion-formatting skill has the commands and the recommended tile content. If no Dashboard exists yet, run setup-notion. Do not announce the Dashboard update to the user unless they ask; just keep it accurate. +For one-off or prose tiles use `scripts/notion.mjs refresh-tile --tile ` with tile ids resolved from `data/notion-ids.json` (`__dashboard.columns`); for the three data tiles prefer `sync-dashboard`. Never edit blocks in place; replace the whole tile (both commands do). The notion-formatting skill has the commands and the recommended tile content. If no Dashboard exists yet, run setup-notion. Do not announce the Dashboard update to the user unless they ask; just keep it accurate. ## Workflow diff --git a/coach-plugin/skills/notion-formatting/SKILL.md b/coach-plugin/skills/notion-formatting/SKILL.md index 8f1dce7..4330948 100644 --- a/coach-plugin/skills/notion-formatting/SKILL.md +++ b/coach-plugin/skills/notion-formatting/SKILL.md @@ -68,17 +68,21 @@ Note: Notion's public API cannot create or configure database *views* (grouping, ## Keep the Dashboard in sync -If the workspace has a Dashboard page (built by setup-notion), keep its tiles current after you change the underlying data, so the user never has to ask. Use the `refresh-tile` command, which replaces the whole tile (never edit blocks in place; their ids shift). Tile ids are cached in `data/notion-ids.json`, so refer to tiles by name: +If the workspace has a Dashboard page (built by setup-notion), keep its tiles current after you change the underlying data, so the user never has to ask. + +**Preferred for the three data tiles (This Week, Goals, Body Stats):** run one command. ```bash -node scripts/notion.mjs refresh-tile --tile goals --md "> [🎯] **Goals**\n- Squat 100kg by Sept" -node scripts/notion.mjs refresh-tile --tile bodyStats --md "..." -node scripts/notion.mjs refresh-tile --tile thisWeek --md "..." +node scripts/notion.mjs sync-dashboard --now 2026-06-30 # --now is the date from the Telegram header ``` -- After adding or changing a goal, refresh the `goals` tile (re-query the Goals DB). -- After logging body weight or stats, refresh the `bodyStats` tile. -- After logging a session, refresh the `thisWeek` tile (re-query the Workout Log for the current week: sessions completed, last session, next session). +`sync-dashboard` re-derives all three tiles directly from the databases (session counts, goal progress, latest body stats), so the numbers are always consistent with the data and never hand-typed. Run it after logging a session, changing a goal, or logging body stats. + +For one-off or prose tiles, `refresh-tile` replaces a single tile from your own markdown (never edit blocks in place; their ids shift). Tile ids are cached in `data/notion-ids.json`, so refer to tiles by name: + +```bash +node scripts/notion.mjs refresh-tile --tile goals --md "> [🎯] **Goals**\n- Squat 100kg by Sept" +``` Rebuild the whole Dashboard (`node scripts/setup-workspace.mjs --rebuild-dashboard`) when the program changes, the week advances, or more than three tiles need updating at once. If no Dashboard exists, do not invent one unprompted; offer to build it via setup-notion. diff --git a/docs/configuration.md b/docs/configuration.md index 22f8469..4ff1457 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,8 +31,9 @@ what gets built. |---|---|---| | `AGENT_NAME` | `Coach` | What the coach calls itself (plain-coach mode). | | `TIMEZONE` | `Europe/London` | Timezone for reminders and the message-header clock. | -| `CLAUDE_MODEL` | `claude-sonnet-4-6` | Model for real coaching, planning, and advice. | -| `CLAUDE_MODEL_FAST` | `claude-haiku-4-5` | Cheaper model used for trivial logging messages. | +| `CLAUDE_MODEL` | `claude-sonnet-4-6` | Model for real coaching, planning, and advice (and for everything unless the fast tier is enabled). Set to `claude-opus-4-8` for Opus. | +| `CLAUDE_MODEL_FAST` | `claude-haiku-4-5` | Cheaper model used for trivial logging messages, only when `FAST_TIER_ENABLED=true`. | +| `FAST_TIER_ENABLED` | `false` | When `true`, short transactional logging messages on new chats route to `CLAUDE_MODEL_FAST` to save subscription usage. Off by default so every message uses `CLAUDE_MODEL` for consistent behaviour. | | `REASONING_EFFORT` | `medium` | Thinking depth for the standard model: `low \| medium \| high \| xhigh \| max`. Lower is faster and cheaper. | | `PERSONALITIES_ENABLED` | `true` | Persona overlays (owner gets Arnold, others a stable random character). `false` keeps everyone on the plain coach. See [customization.md](./customization.md). | @@ -52,9 +53,9 @@ cache (`data/notion-ids.json`) also lives here. | Variable | Default | What it is | |---|---|---| | `MODE` | `polling` (or `webhook` if `PUBLIC_BASE_URL` is set) | `polling` long-polls Telegram (always-on hosts); `webhook` runs an HTTP server for scale-to-zero hosts. | -| `PUBLIC_BASE_URL` | (none) | Required in webhook mode: the service's public HTTPS URL. | +| `PUBLIC_BASE_URL` | (none) | Required in webhook mode: the service's public HTTPS URL. Startup fails fast if `MODE=webhook` and this is unset. | | `PORT` | `8080` | HTTP port (health checks, webhook, scheduler). | -| `CRON_SECRET` | (none) | Required in webhook mode: bearer secret an external scheduler sends to `POST /cron/run`. | +| `CRON_SECRET` | (none) | Required in webhook mode: bearer secret an external scheduler sends to `POST /cron/run`. Startup fails fast if `MODE=webhook` and this is unset, so the reminder endpoint is never left unauthenticated. | See [deployment.md](./deployment.md) for how the two modes map to hosts. diff --git a/scripts/notion.mjs b/scripts/notion.mjs index c0f0a64..883c970 100644 --- a/scripts/notion.mjs +++ b/scripts/notion.mjs @@ -31,6 +31,9 @@ * --set "RPE=8" --set "Exercises=Bench 5x5 @60kg; OHP 3x8 @40kg" * node scripts/notion.mjs append --page --md "# Heading\n> [!note] callout\n---\n- a\n- b" * node scripts/notion.mjs append --page --file plan.md + * node scripts/notion.mjs sync-dashboard --now 2026-06-30 + * + * Add --dry-run to `log` to validate and print the parsed row without writing it. */ import fs from "node:fs"; import path from "node:path"; @@ -105,7 +108,13 @@ function readCache() { } function writeCache(cache) { fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true }); - fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2)); + // Atomic write: a crash mid-write must not truncate the cache to invalid JSON + // (which readCache would silently swallow as {}, losing every resolved id and + // causing duplicate databases on the next setup). Write to a temp file and + // rename, which is atomic on the same filesystem. + const tmp = `${CACHE_FILE}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(cache, null, 2)); + fs.renameSync(tmp, CACHE_FILE); } /** Accept a raw id or a database name; resolve names via cache then search. */ @@ -170,6 +179,43 @@ function buildPropertyValue(type, raw) { } } +/** The allowed option names for a select/multi_select/status property, else null. */ +function optionNames(def) { + if (def.type === "select") return (def.select?.options ?? []).map((o) => o.name); + if (def.type === "status") return (def.status?.options ?? []).map((o) => o.name); + if (def.type === "multi_select") return (def.multi_select?.options ?? []).map((o) => o.name); + return null; +} + +/** + * Validate a --set value against the live schema before it is written, so a + * miscategorised or out-of-range value fails locally with the valid options + * (the same self-correcting pattern as the unknown-property check) instead of + * silently creating a stray select option or logging an impossible number. + * Pure (no I/O) so it is unit-testable; throws on the first problem. + */ +function validateValue(name, def, value) { + const allowed = optionNames(def); + if (allowed) { + const given = def.type === "multi_select" ? value.split(",").map((v) => v.trim()) : [value]; + for (const v of given) { + if (v && !allowed.includes(v)) { + throw new Error(`"${v}" is not a valid option for "${name}". Allowed: ${allowed.join(", ")}`); + } + } + } + if (def.type === "number") { + const n = Number(value); + if (value !== "" && Number.isNaN(n)) { + throw new Error(`Property "${name}" expects a number, got "${value}".`); + } + // RPE is a fixed 1-10 scale; reject impossible values rather than logging them. + if (/\brpe\b/i.test(name) && (n < 1 || n > 10)) { + throw new Error(`"${name}" must be between 1 and 10, got ${value}.`); + } + } +} + async function buildProperties(dbId, sets) { const db = await notion(`/databases/${dbId}`); const schema = db.properties ?? {}; @@ -181,6 +227,7 @@ async function buildProperties(dbId, sets) { const value = pair.slice(idx + 1); const def = schema[name]; if (!def) throw new Error(`Property "${name}" not found in database. Have: ${Object.keys(schema).join(", ")}`); + validateValue(name, def, value); props[name] = buildPropertyValue(def.type, value); } return props; @@ -375,6 +422,98 @@ function markdownToBlocks(md) { return blocks; } +// ─── reading rows + dashboard rendering ────────────────────────────────────── + +/** Read a Notion property value object into a plain string/number. */ +function readProp(v) { + if (!v) return ""; + if (v.type === "title") return (v.title ?? []).map((t) => t.plain_text).join(""); + if (v.type === "rich_text") return (v.rich_text ?? []).map((t) => t.plain_text).join(""); + if (v.type === "select") return v.select?.name ?? ""; + if (v.type === "status") return v.status?.name ?? ""; + if (v.type === "multi_select") return (v.multi_select ?? []).map((s) => s.name).join(", "); + if (v.type === "number") return v.number ?? ""; + if (v.type === "date") return v.date?.start ?? ""; + return ""; +} + +/** Find a property name by a predicate over (lowercased name, definition). */ +function findProp(schema, pred) { + return Object.keys(schema).find((k) => pred(k.toLowerCase(), schema[k])); +} + +const isoDate = (d) => d.toISOString().slice(0, 10); + +/** Monday 00:00 UTC of the week containing d. UTC-based so it is deterministic + * regardless of server timezone (the caller can pass --now from the chat header). */ +function startOfWeekUTC(d) { + const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + const mondayOffset = (x.getUTCDay() + 6) % 7; // Sun=0 -> 6, Mon=1 -> 0 + x.setUTCDate(x.getUTCDate() - mondayOffset); + return x; +} + +// Pure tile renderers: take plain extracted rows and return tile markdown. The +// numbers come straight from Notion queries (never the model), which is what +// keeps the Dashboard honest. Exported for unit tests. +function renderThisWeekTile(sessions) { + const lines = ["> [📅] **This Week**"]; + if (!sessions.length) { + lines.push("- No sessions logged yet this week."); + } else { + lines.push(`- ${sessions.length} session${sessions.length === 1 ? "" : "s"} logged this week`); + const last = sessions[0]; + const focus = last.focus ? ` (${last.focus})` : ""; + lines.push(`- Last: ${[last.date, last.name].filter(Boolean).join(" ")}${focus}`); + } + return lines.join("\n"); +} + +function renderGoalsTile(goals) { + const lines = ["> [🎯] **Goals**"]; + const active = goals.filter((g) => g.status !== "Achieved" && g.status !== "Paused"); + const show = (active.length ? active : goals).slice(0, 5); + if (!show.length) { + lines.push("- No goals set yet."); + } else { + for (const g of show) { + const hasNums = g.current !== "" && g.current != null && g.target !== "" && g.target != null; + lines.push(`- ${g.goal}${hasNums ? `: ${g.current} / ${g.target}` : ""}`); + } + } + return lines.join("\n"); +} + +function renderBodyStatsTile(latest) { + const lines = ["> [⚖️] **Body Stats**"]; + if (!latest) { + lines.push("- No check-ins logged yet."); + } else { + const parts = []; + if (latest.bodyweight !== "" && latest.bodyweight != null) parts.push(`${latest.bodyweight}kg`); + if (latest.waist !== "" && latest.waist != null) parts.push(`waist ${latest.waist}cm`); + lines.push(`- ${[latest.date, parts.join(", ")].filter(Boolean).join(": ")}`); + } + return lines.join("\n"); +} + +/** + * Replace a column's children with fresh content, append-then-delete: write the + * new blocks first, then delete the previously-existing ones. A failure mid-append + * leaves the old tile intact (brief duplication) rather than wiping it to empty. + * Returns the number of blocks written. + */ +async function replaceTileContent(columnId, md) { + const existing = await notion(`/blocks/${columnId}/children`); + const oldIds = (existing.results ?? []).map((b) => b.id); + const blocks = markdownToBlocks(md); + for (let i = 0; i < blocks.length; i += 100) { + await notion(`/blocks/${columnId}/children`, "PATCH", { children: blocks.slice(i, i + 100) }); + } + for (const id of oldIds) await notion(`/blocks/${id}`, "DELETE"); + return blocks.length; +} + // ─── commands ──────────────────────────────────────────────────────────────── async function cmdResolveDb(args) { const id = await resolveDbId(args.name ?? args.db); @@ -406,23 +545,8 @@ async function cmdQueryRecent(args) { const rpeProp = byName("rpe"); const exProp = byName("exercise"); - const get = (name) => { - const v = name ? p[name] : null; - return readValue(v); - function readValue(v) { - if (!v) return ""; - if (v.type === "title") return v.title.map((t) => t.plain_text).join(""); - if (v.type === "rich_text") return v.rich_text.map((t) => t.plain_text).join(""); - if (v.type === "select") return v.select?.name ?? ""; - if (v.type === "status") return v.status?.name ?? ""; - if (v.type === "multi_select") return v.multi_select.map((s) => s.name).join(", "); - if (v.type === "number") return v.number ?? ""; - if (v.type === "date") return v.date?.start ?? ""; - return ""; - } - }; - let p; + const get = (name) => readProp(name ? p[name] : null); for (const page of res.results) { p = page.properties ?? {}; const parts = [get(dateProp), get(titleProp)]; @@ -436,6 +560,13 @@ async function cmdQueryRecent(args) { async function cmdLog(args) { const dbId = await resolveDbId(args.db); const properties = await buildProperties(dbId, args.set); + // --dry-run validates and prints the parsed row without writing, so the coach + // can read it back before committing (supports the "confirm before logging" flow). + if (args["dry-run"]) { + console.log("Dry run, nothing written. Parsed properties:"); + console.log(JSON.stringify(properties, null, 2)); + return; + } const page = await notion("/pages", "POST", { parent: { database_id: dbId }, properties }); console.log(`Logged row ${page.id}`); } @@ -473,13 +604,86 @@ async function cmdRefreshTile(args) { const md = args.file ? fs.readFileSync(args.file, "utf8") : (args.md ?? ""); if (!md.trim()) throw new Error("Nothing to write: pass --md or --file"); - const existing = await notion(`/blocks/${columnId}/children`); - for (const b of existing.results ?? []) await notion(`/blocks/${b.id}`, "DELETE"); - const blocks = markdownToBlocks(md); - for (let i = 0; i < blocks.length; i += 100) { - await notion(`/blocks/${columnId}/children`, "PATCH", { children: blocks.slice(i, i + 100) }); + const written = await replaceTileContent(columnId, md); + console.log(`Refreshed tile ${args.tile ?? columnId} with ${written} block(s)`); +} + +/** + * Rebuild the three Row 1 Dashboard tiles (This Week, Goals, Body Stats) directly + * from the databases, so they are always consistent with the data and never carry + * hand-written (fabricated) numbers. The agent calls this once after any data + * change instead of hand-building each tile. --now sets "this week" + * from the chat header date (defaults to the system date). + */ +async function cmdSyncDashboard(args) { + const cols = readCache().__dashboard?.columns ?? {}; + if (!cols.thisWeek && !cols.goals && !cols.bodyStats) { + throw new Error("No Dashboard columns cached. Run setup-workspace.mjs first."); } - console.log(`Refreshed tile ${args.tile ?? columnId} with ${blocks.length} block(s)`); + const now = args.now ? new Date(args.now) : new Date(); + if (Number.isNaN(now.getTime())) throw new Error(`--now must be an ISO date, got "${args.now}"`); + + // This Week: sessions logged since Monday of the current week. + if (cols.thisWeek) { + const wlId = await resolveDbId("Workout Log"); + const schema = (await notion(`/databases/${wlId}`)).properties ?? {}; + const dateProp = findProp(schema, (_n, d) => d.type === "date"); + const titleProp = findProp(schema, (_n, d) => d.type === "title"); + const focusProp = findProp(schema, (n) => n.includes("focus")); + const body = { page_size: 100 }; + if (dateProp) { + body.filter = { property: dateProp, date: { on_or_after: isoDate(startOfWeekUTC(now)) } }; + body.sorts = [{ property: dateProp, direction: "descending" }]; + } + const res = await notion(`/databases/${wlId}/query`, "POST", body); + const sessions = (res.results ?? []).map((pg) => ({ + date: readProp(pg.properties?.[dateProp]), + name: readProp(pg.properties?.[titleProp]), + focus: focusProp ? readProp(pg.properties?.[focusProp]) : "", + })); + await replaceTileContent(cols.thisWeek, renderThisWeekTile(sessions)); + } + + // Goals: active goals with current/target progress. + if (cols.goals) { + const gId = await resolveDbId("Goals"); + const schema = (await notion(`/databases/${gId}`)).properties ?? {}; + const titleProp = findProp(schema, (_n, d) => d.type === "title"); + const statusProp = findProp(schema, (n) => n.includes("status")); + const curProp = findProp(schema, (n, d) => n.includes("current") && d.type === "number"); + const tgtProp = findProp(schema, (n, d) => n.includes("target") && d.type === "number"); + const res = await notion(`/databases/${gId}/query`, "POST", { page_size: 100 }); + const goals = (res.results ?? []).map((pg) => ({ + goal: readProp(pg.properties?.[titleProp]), + status: statusProp ? readProp(pg.properties?.[statusProp]) : "", + current: curProp ? readProp(pg.properties?.[curProp]) : "", + target: tgtProp ? readProp(pg.properties?.[tgtProp]) : "", + })); + await replaceTileContent(cols.goals, renderGoalsTile(goals)); + } + + // Body Stats: the latest check-in. + if (cols.bodyStats) { + const bId = await resolveDbId("Body Stats"); + const schema = (await notion(`/databases/${bId}`)).properties ?? {}; + const dateProp = findProp(schema, (_n, d) => d.type === "date"); + const bwProp = findProp(schema, (n) => n.includes("bodyweight")); + const waistProp = findProp(schema, (n) => n.includes("waist")); + const body = { page_size: 1 }; + if (dateProp) body.sorts = [{ property: dateProp, direction: "descending" }]; + const res = await notion(`/databases/${bId}/query`, "POST", body); + const row = res.results?.[0]; + const latest = row + ? { + date: readProp(row.properties?.[dateProp]), + bodyweight: bwProp ? readProp(row.properties?.[bwProp]) : "", + waist: waistProp ? readProp(row.properties?.[waistProp]) : "", + } + : null; + await replaceTileContent(cols.bodyStats, renderBodyStatsTile(latest)); + } + + console.log("Synced Dashboard tiles (This Week, Goals, Body Stats) from Notion."); } /** @@ -513,6 +717,7 @@ const COMMANDS = { log: cmdLog, append: cmdAppend, "refresh-tile": cmdRefreshTile, + "sync-dashboard": cmdSyncDashboard, "create-page": cmdCreatePage, }; @@ -541,4 +746,15 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) }); } -export { parseInline, markdownToBlocks, buildPropertyValue }; +export { + parseInline, + markdownToBlocks, + buildPropertyValue, + validateValue, + optionNames, + readProp, + startOfWeekUTC, + renderThisWeekTile, + renderGoalsTile, + renderBodyStatsTile, +}; diff --git a/scripts/notion.test.mjs b/scripts/notion.test.mjs index 7731232..8d814d7 100644 --- a/scripts/notion.test.mjs +++ b/scripts/notion.test.mjs @@ -1,6 +1,16 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { parseInline, markdownToBlocks, buildPropertyValue } from "./notion.mjs"; +import { + parseInline, + markdownToBlocks, + buildPropertyValue, + validateValue, + optionNames, + startOfWeekUTC, + renderThisWeekTile, + renderGoalsTile, + renderBodyStatsTile, +} from "./notion.mjs"; // ─── inline rich text ───────────────────────────────────────────────────────── @@ -109,3 +119,78 @@ test("buildPropertyValue: coerces by schema type", () => { assert.equal(buildPropertyValue("date", "2026-06-23").date.start, "2026-06-23"); assert.equal(buildPropertyValue("checkbox", "yes").checkbox, true); }); + +// ─── value validation ─────────────────────────────────────────────────────────── + +const selectDef = { type: "select", select: { options: [{ name: "Active" }, { name: "Paused" }] } }; +const multiDef = { + type: "multi_select", + multi_select: { options: [{ name: "Chest" }, { name: "Back" }, { name: "Legs" }] }, +}; + +test("optionNames: reads allowed options for select/multi_select, null otherwise", () => { + assert.deepEqual(optionNames(selectDef), ["Active", "Paused"]); + assert.deepEqual(optionNames(multiDef), ["Chest", "Back", "Legs"]); + assert.equal(optionNames({ type: "number" }), null); +}); + +test("validateValue: rejects an unknown select option and lists valid ones", () => { + assert.throws(() => validateValue("Status", selectDef, "Sprinting"), /Allowed: Active, Paused/); +}); + +test("validateValue: accepts a valid select option", () => { + assert.doesNotThrow(() => validateValue("Status", selectDef, "Active")); +}); + +test("validateValue: rejects an unknown member of a multi_select", () => { + assert.throws(() => validateValue("Focus", multiDef, "Chest, Push"), /"Push" is not a valid option/); +}); + +test("validateValue: rejects out-of-range RPE", () => { + assert.throws(() => validateValue("RPE (1-10)", { type: "number" }, "12"), /between 1 and 10/); + assert.doesNotThrow(() => validateValue("RPE (1-10)", { type: "number" }, "8")); +}); + +test("validateValue: rejects a non-numeric number value", () => { + assert.throws(() => validateValue("Volume", { type: "number" }, "lots"), /expects a number/); +}); + +// ─── dashboard rendering (pure, no live Notion) ────────────────────────────────── + +test("startOfWeekUTC: returns the Monday of the week (UTC)", () => { + // 2026-06-30 is a Tuesday → Monday is 2026-06-29. + assert.equal(startOfWeekUTC(new Date("2026-06-30T12:00:00Z")).toISOString().slice(0, 10), "2026-06-29"); + // A Sunday maps back to the previous Monday. + assert.equal(startOfWeekUTC(new Date("2026-07-05T00:00:00Z")).toISOString().slice(0, 10), "2026-06-29"); +}); + +test("renderThisWeekTile: counts sessions and shows the last one", () => { + const md = renderThisWeekTile([ + { date: "2026-06-30", name: "Push A", focus: "Chest" }, + { date: "2026-06-29", name: "Legs", focus: "Legs" }, + ]); + assert.match(md, /\*\*This Week\*\*/); + assert.match(md, /2 sessions logged this week/); + assert.match(md, /Last: 2026-06-30 Push A \(Chest\)/); +}); + +test("renderThisWeekTile: empty state", () => { + assert.match(renderThisWeekTile([]), /No sessions logged yet this week/); +}); + +test("renderGoalsTile: shows active goals with progress, hides achieved", () => { + const md = renderGoalsTile([ + { goal: "Squat 100kg", status: "On track", current: 90, target: 100 }, + { goal: "Old goal", status: "Achieved", current: 1, target: 1 }, + ]); + assert.match(md, /- Squat 100kg: 90 \/ 100/); + assert.doesNotMatch(md, /Old goal/); +}); + +test("renderBodyStatsTile: shows latest weight and waist, or empty state", () => { + assert.match( + renderBodyStatsTile({ date: "2026-06-20", bodyweight: 80, waist: 82 }), + /2026-06-20: 80kg, waist 82cm/, + ); + assert.match(renderBodyStatsTile(null), /No check-ins logged yet/); +}); diff --git a/scripts/setup-workspace.mjs b/scripts/setup-workspace.mjs index 5e52fee..365ae8e 100644 --- a/scripts/setup-workspace.mjs +++ b/scripts/setup-workspace.mjs @@ -88,7 +88,11 @@ function readCache() { } function writeCache(c) { fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true }); - fs.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2)); + // Atomic write (temp + rename): a crash mid-write must not leave a truncated + // cache that readCache swallows as {}, which would re-create duplicate databases. + const tmp = `${CACHE_FILE}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(c, null, 2)); + fs.renameSync(tmp, CACHE_FILE); } const cache = readCache(); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index d535da1..18cb9ad 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -1,8 +1,10 @@ import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { config } from "../config.js"; import { getSession, setSession, clearSession } from "./sessions.js"; +import { notionConfigured } from "../notion/status.js"; import { type Attachment, buildUserContent } from "../media/attachments.js"; import { pickPersonality, personaSystemPrompt } from "./personalities.js"; @@ -67,6 +69,62 @@ export interface AgentResponse { sessionId?: string; } +// Whole-query retry for transient startup failures. +const MAX_QUERY_ATTEMPTS = 3; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** The Claude subscription cap (reset on a rolling window), not a transient limit. */ +function isUsageLimit(msg: string): boolean { + const l = msg.toLowerCase(); + return ( + l.includes("usage limit") || + l.includes("usage_limit") || + l.includes("credit balance is too low") || + l.includes("insufficient credit") || + l.includes("quota exceeded") + ); +} + +/** Transient network/overload errors worth a quick retry. */ +function isTransient(msg: string): boolean { + const l = msg.toLowerCase(); + return /etimedout|econnreset|econnrefused|enotfound|eai_again|socket hang up|network|fetch failed|overloaded|\b50[234]\b|service unavailable|timed out|timeout/.test( + l, + ); +} + +/** + * A short, code-computed context line prepended to every prompt so the facts the + * onboarding routine depends on are present deterministically on every turn + * (including resumed sessions, where the model would otherwise skip onboarding). + * Reports Notion/workspace status and whether PERSONAL.md is filled in. + */ +function buildSessionContext(): string { + const parts: string[] = []; + if (notionConfigured()) { + parts.push("Notion configured"); + const idsPath = path.join(PROJECT_ROOT, "data", "notion-ids.json"); + parts.push(fs.existsSync(idsPath) ? "workspace built" : "workspace not built yet (run setup-notion)"); + } else { + parts.push("Notion not configured (no persistence)"); + } + + const personalPath = path.join(PROJECT_ROOT, "PERSONAL.md"); + if (!fs.existsSync(personalPath)) { + parts.push("PERSONAL.md missing (ask for goals and how they eat, then create it)"); + } else { + let looksTemplate = false; + try { + // The example's first goal; if it survives, the file was not personalised. + looksTemplate = fs.readFileSync(personalPath, "utf-8").includes("e.g. Add 10kg to my squat by September"); + } catch { + /* unreadable: treat as set, the agent can still ask */ + } + parts.push(looksTemplate ? "PERSONAL.md still has placeholder goals (ask the user to fill them)" : "PERSONAL.md set"); + } + return parts.join("; "); +} + /** * Run the Claude agent for one incoming message. * @@ -93,92 +151,101 @@ export async function runAgent(opts: { const model = modelTier === "standard" ? config.model : config.modelFast; const now = new Date().toLocaleString("en-GB", { timeZone: config.timezone }); - const prompt = `[Telegram chat ${chatId}${userLabel ? ` | ${userLabel}` : ""} | local time: ${now} (${config.timezone})]\n${userMessage}`; + const context = buildSessionContext(); + const prompt = `[Telegram chat ${chatId}${userLabel ? ` | ${userLabel}` : ""} | local time: ${now} (${config.timezone})]\n[context: ${context}]\n${userMessage}`; const persona = pickPersonality(chatId); const personaAppend = personaSystemPrompt(persona); - let resultText = ""; - let sessionId: string | undefined; - console.log(`[agent] Starting query (${model}, ${modelTier}, persona=${persona.id}):`, userMessage.slice(0, 100)); - try { - for await (const message of query({ - prompt: singleMessage(prompt, attachments), - options: { - cwd: PROJECT_ROOT, - model, - effort: config.reasoningEffort, - title: `tg-${chatId}`, - systemPrompt: { - type: "preset", - preset: "claude_code", - excludeDynamicSections: true, - ...(personaAppend ? { append: personaAppend } : {}), + for (let attempt = 0; ; attempt++) { + let resultText = ""; + let sessionId: string | undefined; + try { + for await (const message of query({ + prompt: singleMessage(prompt, attachments), + options: { + cwd: PROJECT_ROOT, + model, + effort: config.reasoningEffort, + title: `tg-${chatId}`, + systemPrompt: { + type: "preset", + preset: "claude_code", + excludeDynamicSections: true, + ...(personaAppend ? { append: personaAppend } : {}), + }, + resume: existingSession, + // Load the project CLAUDE.md (the coach persona) and surface every skill + // discovered from the loaded plugin. skills: "all" is the SDK's single + // place to turn skills on; without it, plugin SKILL.md discovery is left + // to ambiguous defaults and the coach may never see its skills. + settingSources: ["project"], + skills: "all", + allowedTools: [ + "Bash", "Read", "Write", "Edit", "Glob", "Grep", + "WebFetch", "WebSearch", "Skill", + ], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: modelTier === "fast" ? 15 : 60, + plugins: [{ type: "local", path: path.join(PROJECT_ROOT, "coach-plugin") }], + forwardSubagentText: onProgress != null, }, - resume: existingSession, - // Load the project CLAUDE.md (the coach persona) and surface every skill - // discovered from the loaded plugin. skills: "all" is the SDK's single - // place to turn skills on; without it, plugin SKILL.md discovery is left - // to ambiguous defaults and the coach may never see its skills. - settingSources: ["project"], - skills: "all", - allowedTools: [ - "Bash", "Read", "Write", "Edit", "Glob", "Grep", - "WebFetch", "WebSearch", "Skill", - ], - permissionMode: "bypassPermissions", - allowDangerouslySkipPermissions: true, - maxTurns: modelTier === "fast" ? 15 : 60, - plugins: [{ type: "local", path: path.join(PROJECT_ROOT, "coach-plugin") }], - forwardSubagentText: onProgress != null, - }, - })) { - if (message.type === "system" && message.subtype === "init") { - sessionId = (message as any).session_id; - if (sessionId) setSession(chatId, sessionId); + })) { + if (message.type === "system" && message.subtype === "init") { + sessionId = (message as any).session_id; + if (sessionId) setSession(chatId, sessionId); + } + + if ( + onProgress && + message.type === "assistant" && + (message as any).parent_tool_use_id != null + ) { + const blocks = (message as any).message?.content ?? []; + const text = blocks + .filter((b: any) => b?.type === "text" && typeof b.text === "string") + .map((b: any) => b.text as string) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (text) onProgress(text); + } + + if ("result" in message) { + resultText = (message as any).result ?? ""; + } } - if ( - onProgress && - message.type === "assistant" && - (message as any).parent_tool_use_id != null - ) { - const blocks = (message as any).message?.content ?? []; - const text = blocks - .filter((b: any) => b?.type === "text" && typeof b.text === "string") - .map((b: any) => b.text as string) - .join(" ") - .replace(/\s+/g, " ") - .trim(); - if (text) onProgress(text); + return { text: resultText, sessionId }; + } catch (err: any) { + const msg = err?.message ?? String(err); + console.error("[agent] query() threw:", msg); + + // Subscription usage limit hit. Return a clear message rather than throwing. + // No restart needed: the next query() spawns a fresh subprocess, so once the + // limit window resets the very next request will work. + if (isUsageLimit(msg)) { + clearSession(chatId); + return { + text: "I have hit the Claude subscription usage limit. Limits reset on a rolling window, so try again shortly. No restart needed.", + sessionId: undefined, + }; } - if ("result" in message) { - resultText = (message as any).result ?? ""; + // Transient failure before the agent started (no session id yet, so no tool + // side effects have run): safe to retry with backoff. Once a session is + // established we do not blind-retry, to avoid duplicate Notion writes. + if (isTransient(msg) && sessionId === undefined && attempt < MAX_QUERY_ATTEMPTS - 1) { + const waitMs = Math.min(2 ** attempt, 8) * 1000; + console.warn(`[agent] transient error; retry ${attempt + 1}/${MAX_QUERY_ATTEMPTS - 1} in ${waitMs}ms`); + await sleep(waitMs); + continue; } - } - } catch (err: any) { - const msg = err?.message ?? String(err); - console.error("[agent] query() threw:", msg); - - // Subscription usage limit hit. Return a clear message rather than throwing. - // No restart needed: the next query() spawns a fresh subprocess, so once the - // limit window resets the very next request will work. - if ( - msg.toLowerCase().includes("usage limit") || - msg.toLowerCase().includes("credit balance is too low") - ) { - clearSession(chatId); - return { - text: "I have hit the Claude subscription usage limit. Limits reset on a rolling window, so try again shortly. No restart needed.", - sessionId: undefined, - }; - } - throw err; + throw err; + } } - - return { text: resultText, sessionId }; } diff --git a/src/agent/sessions.ts b/src/agent/sessions.ts index bd2a1fc..5a145cb 100644 --- a/src/agent/sessions.ts +++ b/src/agent/sessions.ts @@ -8,6 +8,7 @@ import fs from "fs"; import path from "path"; import os from "os"; import { config } from "../config.js"; +import { writeJsonAtomic } from "../util/atomicfile.js"; interface SessionEntry { sessionId: string; @@ -52,14 +53,11 @@ function loadFromDisk(): void { /** Persist current sessions to disk. */ function saveToDisk(): void { try { - fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true }); const data: Record = {}; for (const [key, entry] of sessions) { data[key] = entry; } - const tmpFile = SESSION_FILE + ".tmp"; - fs.writeFileSync(tmpFile, JSON.stringify(data), "utf-8"); - fs.renameSync(tmpFile, SESSION_FILE); + writeJsonAtomic(SESSION_FILE, data); } catch (err) { console.warn("[sessions] Failed to persist sessions to disk:", (err as Error).message); } diff --git a/src/config.ts b/src/config.ts index 750e628..8838412 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,10 +36,15 @@ export const config = { // "normal" for the plain coach, or "random" for a stable random character. ownerPersona: process.env.OWNER_PERSONA ?? "arnold", - // Models. Sonnet for real coaching, the fast model for trivial logging. + // Models. The main model for real coaching, the fast model for trivial logging. model: process.env.CLAUDE_MODEL ?? "claude-sonnet-4-6", modelFast: process.env.CLAUDE_MODEL_FAST ?? "claude-haiku-4-5", + // Route short, clearly-transactional logging messages to the cheaper fast model. + // Off by default so behaviour is consistent out of the box (every message uses + // CLAUDE_MODEL); opt in to save subscription usage on trivial logs. + fastTierEnabled: (process.env.FAST_TIER_ENABLED ?? "false") === "true", + // Reasoning effort for the standard model: low | medium | high | xhigh | max. // Lower means faster, cheaper replies with less deep thinking. reasoningEffort: (process.env.REASONING_EFFORT ?? "medium") as @@ -82,9 +87,10 @@ function requireEnv(name: string): string { } /** - * Validate Claude auth at startup. Fails fast with a clear message if the - * subscription token is missing (otherwise the agent only fails later with a - * 401), which is exactly the misconfiguration that is easy to miss. + * Validate configuration at startup, failing fast with a clear message rather than + * letting a misconfiguration surface later as a 401 or a silently insecure deploy. + * Covers Claude auth and, in webhook mode, the settings without which the service + * either cannot register its webhook or would expose an unauthenticated /cron/run. */ export function checkClaudeAuth(): void { if (!config.claudeOauthToken) { @@ -94,4 +100,19 @@ export function checkClaudeAuth(): void { ); } console.log("[config] Claude auth: subscription token"); + + if (config.mode === "webhook") { + if (!config.publicBaseUrl) { + throw new Error( + "MODE=webhook requires PUBLIC_BASE_URL (the service's public HTTPS URL) so the " + + "Telegram webhook can be registered.", + ); + } + if (!config.cronSecret) { + throw new Error( + "MODE=webhook requires CRON_SECRET so the /cron/run reminder endpoint is " + + "authenticated. Set a strong random value and share it with your scheduler.", + ); + } + } } diff --git a/src/index.ts b/src/index.ts index 97482c7..3b75843 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,11 +99,15 @@ async function handle(opts: { // Keep a typing indicator alive while the agent works (it lasts ~5s per call). const typingTimer = setInterval(() => typing().catch(() => {}), 4500); - // Route trivial logging messages to the fast model on new chats only. Existing - // sessions stay on the standard model to avoid switching models mid-conversation. - // A message carrying an attachment is never trivial, so it stays on standard. + // Route trivial logging messages to the fast model on new chats only, and only + // when the fast tier is enabled (off by default for consistent behaviour). + // Existing sessions stay on the standard model to avoid switching mid-conversation, + // and a message carrying an attachment is never trivial, so it stays on standard. const existingSession = getSession(chatId); - const modelTier = existingSession || attachments.length > 0 ? "standard" : classifyQuery(text); + const modelTier = + config.fastTierEnabled && !existingSession && attachments.length === 0 + ? classifyQuery(text) + : "standard"; try { const response = await runAgent({ diff --git a/src/scheduler/scheduler.ts b/src/scheduler/scheduler.ts index 2890e79..97981af 100644 --- a/src/scheduler/scheduler.ts +++ b/src/scheduler/scheduler.ts @@ -1,9 +1,9 @@ import fs from "fs"; -import path from "path"; import { Cron } from "croner"; import { config } from "../config.js"; import { runAgent, type ModelTier } from "../agent/runner.js"; import { sendMessage } from "../channel/notify.js"; +import { writeJsonAtomic } from "../util/atomicfile.js"; /** * Cron-based scheduler for proactive coaching: morning session nudges, the weekly @@ -110,6 +110,9 @@ export async function executeTask(task: ScheduledTask): Promise { } export function addTask(task: ScheduledTask): void { + if (definitions.some((t) => t.id === task.id)) { + console.warn(`[scheduler] Task id "${task.id}" already exists; replacing it.`); + } definitions = [...definitions.filter((t) => t.id !== task.id), task]; // Only start a live croner job in polling mode; webhook mode is fired externally. if (config.mode === "polling") startTask(task); @@ -158,8 +161,7 @@ function loadFromDisk(): ScheduledTask[] { function saveToDisk(all: ScheduledTask[]): void { try { - fs.mkdirSync(path.dirname(SCHEDULE_FILE), { recursive: true }); - fs.writeFileSync(SCHEDULE_FILE, JSON.stringify(all, null, 2), "utf-8"); + writeJsonAtomic(SCHEDULE_FILE, all); } catch (err) { console.warn("[scheduler] Failed to persist schedule file:", (err as Error).message); } diff --git a/src/util/atomicfile.ts b/src/util/atomicfile.ts new file mode 100644 index 0000000..bfc99d1 --- /dev/null +++ b/src/util/atomicfile.ts @@ -0,0 +1,16 @@ +import fs from "fs"; +import path from "path"; + +/** + * Write JSON to disk atomically: serialise to a sibling temp file, then rename + * over the target. rename(2) is atomic on the same filesystem, so a crash mid-write + * can never leave a half-written file that the next read parses as empty (and so + * silently loses sessions, the schedule, or the Notion id cache). Mirrors the + * pattern used by the Notion helper scripts. + */ +export function writeJsonAtomic(filePath: string, data: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(data), "utf-8"); + fs.renameSync(tmp, filePath); +}