From 716da84470a5b9f38a57af45ff34d6475403274f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:02:31 -0400 Subject: [PATCH 01/26] docs(spec): add loop mode + poetic closing design Replaces the once-daily schedule with a 3-minute loop, makes the message more varied (date + location + weather + crypto + closest-match context, ending with a haiku), and adds curl commands to start and stop the loop. One writer, dedup made optional via env var. --- .../2026-08-09-loop-mode-poetry-design.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md new file mode 100644 index 0000000..9486ea7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -0,0 +1,211 @@ +# Loop Mode + Poetic Closing — Design + +**Date:** 2026-08-09 +**Status:** Draft (pending user review) +**Scope:** Replaces the once-daily schedule with a 3-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + closest-match context, ending with a short haiku), and adds curl commands to start and stop the loop. One writer, dedup made optional via env var, no new user-facing tutorial doc. + +--- + +## 1. Purpose and constraints + +The base tutorial posts one Discord message per day, on value change only. For local testing and quick iteration, a 3-minute cadence is more useful — you can see the LLM's output in seconds, not hours — and the message should be more varied (incorporating date, location, and a creative closing) rather than the current template-y "Weather update: 72F" / "Crypto update: 67234.10". + +**Constraints carried over from the base spec:** standalone, TypeScript/Node 24/ESM, public-tutorial quality, no VPC/DB server, single-writer invariant, single-user, reader stays read-only. + +**New constraints this feature must respect:** + +- **One writer.** The existing `runFetch` is the only writer path. No parallel `runLoopTick` that forks the semantics. Any loop-specific behavior is a small change inside `runFetch` plus a new enable/disable gate. +- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the curl commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~960 Bedrock calls/day. No new `docs/0X-*.md`. +- **Numerology is out.** The closing beat is a short haiku the LLM generates, not a numerology sentence. LLMs produce cliché numerology platitudes; a haiku gives them actual creative room and reads as varied across runs. + +--- + +## 2. Architecture + +The deploy changes: +- A new EventBridge schedule at `rate(3 minutes)` replaces the existing `rate(1 day)` schedule (single edit in `infra/stack.ts`). +- The schedule invokes the same Lambda with the same `{op:"fetch"}` payload. No new op for the scheduled tick. + +The runtime changes: +- `runFetch` reads two new pieces of state: a `FETCH_DEDUP` env var (default `false`) and a `loop_enabled` setting in the SQLite snapshot (default `true` after first deploy, since the loop is now the primary mode). If `loop_enabled` is `'false'`, `runFetch` returns early without fetching, formatting, or posting. +- When `FETCH_DEDUP` is `false` (the default), the per-source dedup check is skipped — every tick produces one `agent_notifications` row per source and one Discord post per source. When `true`, the existing per-source value-change dedup is preserved (useful if someone wants the original daily-style low-cost behavior). +- The formatter's user prompt gains structured fields: `date`, `location`, `weatherValue`, `cryptoValue`, in addition to the existing `source` and `rawValue`. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, (2) naturally reference the closest past reading if provided, and (3) end with a brief haiku (5-7-5) that incorporates the temperature, the crypto value, and the day's vibe. + +The control plane changes: +- Two new ops, `loop-start` and `loop-stop`, gated by a new `LOOP_TOKEN` env var (same constant-time check pattern as `FETCH_TRIGGER_TOKEN`). They write `'true'`/`'false'` to a new `agent_settings` row, then re-publish the snapshot with the updated ETag. Default-deny if `LOOP_TOKEN` is unset. +- The status endpoint gains a `loopEnabled: boolean` field so you can see the current state via the existing `op:"status"` call. + +``` +EventBridge rate(3 min) ──> Lambda (op:"fetch") + │ + ▼ + runFetch + │ + ├─ read agent_settings.loop_enabled + │ └─ 'false' → return early (no fetch, no post) + │ + ├─ read FETCH_DEDUP env var + │ └─ 'false' (default) → skip dedup check + │ └─ 'true' → keep current dedup behavior + │ + ├─ per-source: + │ fetch, findNearestMatch (RAG), + │ formatter.format(ctx), post to Discord, + │ insert agent_notifications row, embed + store + │ + └─ publish snapshot to S3 (conditional) + +curl "FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' + │ + └─ writes agent_settings.loop_enabled='false', + re-publishes snapshot +``` + +--- + +## 3. Data model + +### 3.1 New table: `agent_settings` + +```sql +CREATE TABLE IF NOT EXISTS agent_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +One row currently: `(key='loop_enabled', value='true' | 'false')`. The shape is generic (key-value) rather than purpose-built so future settings don't require another schema edit — same pattern the base spec uses for `agent_runs.error` (nullable absence is meaningful, not a placeholder). + +### 3.2 No change to existing tables + +`agent_sources`, `agent_notifications`, `agent_runs`, and `agent_embeddings` are unchanged. `agent_notifications` will grow ~28,800 rows/day at 3-min cadence with `FETCH_DEDUP=false` (2 sources × 480 ticks × ~2 rows/tick counting closest-match bookkeeping), which is a known concern documented in `docs/07-budget-protection.md`. No TTL/cap is added in this scope — adding one would change the RAG "every posted message is searchable" invariant and belongs in a separate spec. + +--- + +## 4. New / changed modules + +### 4.1 `src/config.ts` — new env vars + +- `FETCH_DEDUP` (string, parsed as `boolean`, default `false`): when `true`, preserves the original per-source value-change dedup; when `false` (default), `runFetch` always posts. +- `LOOP_TOKEN` (string, optional, default `null`): gates the new `loop-start` / `loop-stop` ops. Same default-deny posture as `FETCH_TRIGGER_TOKEN`. + +Both reuse the existing `str` / `optionalStr` / `num` helpers — no new parser. + +### 4.2 `src/format/types.ts` — new `FormatContext` + +```typescript +export interface FormatContext { + source: SourceName; + rawValue: string; + date: string; // ISO date, e.g. "2026-08-09" + location: string; // e.g. "NYC" + weatherValue: string; // current weather for the location, e.g. "72F" + cryptoValue: string; // current BTC USD price as a string, e.g. "67234.10" + similarPast: SimilarPastResult | null; +} + +export interface MessageFormatter { + format(ctx: FormatContext): Promise; +} +``` + +The signature change is internal — `MessageFormatter` is not exported from the package, only consumed by `runFetch` and `localFetch`. `LocalTemplateFormatter` and `BedrockFormatter` are updated to match. + +### 4.3 `src/format/bedrock.ts` — new system prompt + +``` +SYSTEM: +You write a short, friendly Discord message for a daily-checkin bot that +posts a weather and crypto snapshot every few minutes. The user message +below contains today's date, the location, the current weather, the current +crypto value, and (when available) the closest past reading. Write a brief +comment (one or two sentences) that draws on these inputs — vary your +phrasing across runs; do not repeat the same template. If a closest past +reading is included, you may naturally reference it, but you are not +required to. End with a short haiku (three lines, 5-7-5 syllables) that +weaves in the temperature, the crypto value, and the day's vibe. Reply +with the message text only — no quotes, no preamble, no markdown. +``` + +The local template formatter is updated to a minimal `{date} — {weatherValue} / {cryptoValue} / ` shape so its tests still pin the new context fields. It is not expected to generate a haiku — it's a test-only stub. + +### 4.4 `src/agent/fetch.ts` — loop gate + dedup toggle + +- Reads `loop_enabled` from `agent_settings` at the start. If `'false'`, returns `{ outcome: 'success', sourcesChecked: 0, notificationsSent: 0, error: null }` without writing a run row, fetching, or publishing (so a stopped loop is a no-op every tick, not a record-keeping churn). +- Reads `FETCH_DEDUP` via `loadConfig`. If `false`, the per-source `rawValue === lastValue` check is skipped; every source is formatted and posted unconditionally. +- The per-source loop body (fetch, RAG lookup, format, post, insert, embed) is otherwise unchanged. + +### 4.5 `src/handler.ts` — new ops + +- `op === 'loop-start'` / `op === 'loop-stop'`: same `requestContext`-gated token check as the existing `fetch` trigger. On success, write to `agent_settings` via a new `setSetting(db, key, value)` helper in `src/db/settings.ts`, then publish the snapshot with the current ETag (matching `runFetch`'s conditional-write pattern). Return `{ statusCode: 200, body: JSON.stringify({ loopEnabled: }) }`. +- The `status` op gains a `loopEnabled` field in `StatusResult`, populated from `agent_settings` with a default of `true` if the row is missing (e.g., a snapshot from before this feature shipped). + +### 4.6 `src/agent/status.ts` — expose `loopEnabled` + +Reads the setting on every `getStatus` call. The reader already reads from the snapshot, so this is a small additive change to the SQL. + +### 4.7 `infra/stack.ts` — schedule replacement + +Replace the existing `rate(1 day)` schedule with `rate(3 minutes)`. Single line change. No new infra (no DLQ, no alarm — the loop is for local testing, not unattended operation; this is called out in the README). + +### 4.8 `scripts/loop-start.sh` / `scripts/loop-stop.sh` + +Two new scripts matching the style of `scripts/smoke.sh`: tempfiles for sensitive inputs (the URL, the token), `curl` with the right op, parse the JSON response, print success/failure. Both expect `FUNCTION_URL` and `LOOP_TOKEN` in the environment (sourced from `.env.discord` like the existing smoke script). + +--- + +## 5. Behavioral changes summary + +| Scenario | Before | After | +|---|---|---| +| Daily fetch at 1 day | One fetch per day, dedup on | Still works if `FETCH_DEDUP=true`; the schedule is the only change at the infra layer | +| Loop at 3 min | n/a | Two Discord messages per tick (one weather, one crypto) | +| Message content | "Weather update: 72F" | Date, location, weather, crypto, optional closest-past reference, ends with a haiku | +| Dedup on value change | Always on | Off by default; opt-in via `FETCH_DEDUP=true` | +| Stop the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-stop"}'`. Loop ticks return early; no fetch, no post, no S3 publish. | +| Start the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-start"}'`. Subsequent ticks post as normal. | +| Status endpoint | snapshotVersion, sources, recentNotifications | Adds `loopEnabled: boolean` | + +--- + +## 6. Error handling + +Loop-specific failures follow the same isolation rules as the base spec: + +- A `loop-start` / `loop-stop` op that fails the conditional write is reported in the `agent_runs.error` field (the run row is written before the publish, so a publish failure is recorded in-process; the next tick retries the full sequence). +- A `runFetch` invocation that runs while `loop_enabled='false'` is a no-op. It does not write an `agent_runs` row (so the table is not filled with empty rows at 3-min cadence), and it does not republish the snapshot (so the S3 object is not touched). +- A formatter error per source is caught by the existing per-source `try`/`catch` and folded into `agent_runs.error`. The other source still posts. +- A Discord 4xx is not retried (existing behavior); a 5xx gets one ~250ms retry (existing). Same rules apply. + +--- + +## 7. Testing + +Unit tests (vitest) for: + +- `runFetch` with `loop_enabled='false'`: short-circuits, no fetch, no post, no publish, no run row. +- `runFetch` with `FETCH_DEDUP=false`: two unchanged-value ticks both post. +- `runFetch` with `FETCH_DEDUP=true`: an unchanged-value tick is deduped (existing behavior, regression test). +- `loop-start` / `loop-stop` ops: write to `agent_settings`, publish the snapshot, return the new `loopEnabled` value. +- `loop-start` / `loop-stop` without `LOOP_TOKEN`: 403, no DB write, no publish. +- Status endpoint: `loopEnabled` reflects the current setting (true / false / missing → default true). +- Formatter receives the new `FormatContext` shape and the new fields are present in the Bedrock request body (via the existing `aws-sdk-client-mock` ConverseCommand matcher). + +The RAG corpus-bloat concern is called out in `docs/07-budget-protection.md` as a known limitation; no automated test for it (it would require running the loop for hours to measure). + +--- + +## 8. Docs + +Per the user instruction, no new `docs/0X-*.md` tutorial file. Two small changes: + +- **`README.md`** — new "Loop mode" subsection under "Quick start" with the curl commands for `loop-start` / `loop-stop`, the env vars (`FETCH_DEDUP`, `LOOP_TOKEN`), and a one-line note that the loop is for local testing. +- **`docs/07-budget-protection.md`** — one paragraph added: with `FETCH_DEDUP=false` at 3-min cadence, expect ~960 Bedrock calls/day (2 sources × 480 ticks). At default model pricing, this is roughly $0.05–$0.10/day vs ~$0.02/year for the once-daily fetch. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~28,800 rows/day; if left running unattended this is the primary cost driver, not the Bedrock calls themselves. + +--- + +## 9. Open concerns (out of scope for this spec) + +- **RAG corpus bloat.** At 3-min cadence with `FETCH_DEDUP=false`, `agent_notifications` and `agent_embeddings` grow by ~28,800 rows/day each. The reader and RAG KNN queries still work, but the SQLite file gets larger and S3 storage cost grows linearly. A future spec could add a TTL or a sliding-window cap; this spec does not. +- **Loop token rotation.** `LOOP_TOKEN` is an env var set at deploy time; rotating it requires a redeploy. Same posture as `FETCH_TRIGGER_TOKEN`, called out here for consistency. +- **Unattended operation.** The loop is intended for local testing, not for being left on. The README's "Loop mode" subsection should explicitly say "stop the loop when you're done." From dbc98870259b1c3e476e679fda8fdf5c1ad1696d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:03:05 -0400 Subject: [PATCH 02/26] fix(spec): tighten three points after self-review - Clarify MessageFormatter signature change is internal (not a public surface) - Correct loop-start/loop-stop publish-failure semantics: change is lost on conditional-write failure (same posture as runFetch), not retried by the next tick which hydrates from S3 - Make the FETCH_DEDUP=true regression test scope explicit --- .../superpowers/specs/2026-08-09-loop-mode-poetry-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index 9486ea7..b0664c6 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -109,7 +109,7 @@ export interface MessageFormatter { } ``` -The signature change is internal — `MessageFormatter` is not exported from the package, only consumed by `runFetch` and `localFetch`. `LocalTemplateFormatter` and `BedrockFormatter` are updated to match. +The signature change is internal — `MessageFormatter` is a TypeScript type used only by `runFetch` and `localFetch` (and the format module's own tests); it is not a public package surface. `LocalTemplateFormatter` and `BedrockFormatter` are updated to match. ### 4.3 `src/format/bedrock.ts` — new system prompt @@ -172,7 +172,7 @@ Two new scripts matching the style of `scripts/smoke.sh`: tempfiles for sensitiv Loop-specific failures follow the same isolation rules as the base spec: -- A `loop-start` / `loop-stop` op that fails the conditional write is reported in the `agent_runs.error` field (the run row is written before the publish, so a publish failure is recorded in-process; the next tick retries the full sequence). +- A `loop-start` / `loop-stop` op that fails the conditional write loses the setting change: the local DB has the new value, but the next invocation hydrates from S3 (which still has the old snapshot) and the change is not visible. The same posture as the existing `runFetch` publish-failure path (base spec §4.2). The user must re-run the curl; the failed attempt is logged in-process but not visible to a fresh reader. - A `runFetch` invocation that runs while `loop_enabled='false'` is a no-op. It does not write an `agent_runs` row (so the table is not filled with empty rows at 3-min cadence), and it does not republish the snapshot (so the S3 object is not touched). - A formatter error per source is caught by the existing per-source `try`/`catch` and folded into `agent_runs.error`. The other source still posts. - A Discord 4xx is not retried (existing behavior); a 5xx gets one ~250ms retry (existing). Same rules apply. @@ -185,7 +185,7 @@ Unit tests (vitest) for: - `runFetch` with `loop_enabled='false'`: short-circuits, no fetch, no post, no publish, no run row. - `runFetch` with `FETCH_DEDUP=false`: two unchanged-value ticks both post. -- `runFetch` with `FETCH_DEDUP=true`: an unchanged-value tick is deduped (existing behavior, regression test). +- `runFetch` with `FETCH_DEDUP=true`: an unchanged-value tick is deduped (regression test for the existing dedup path, which is now opt-in). - `loop-start` / `loop-stop` ops: write to `agent_settings`, publish the snapshot, return the new `loopEnabled` value. - `loop-start` / `loop-stop` without `LOOP_TOKEN`: 403, no DB write, no publish. - Status endpoint: `loopEnabled` reflects the current setting (true / false / missing → default true). From 8414c4f055ba0c9d7067ad8c15441b756b359348 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:13:54 -0400 Subject: [PATCH 03/26] fix(spec): strip dedup; loop-start/stop disable EventBridge rule - Drop the FETCH_DEDUP env var and the dedup code path in runFetch. The writer always posts; the loop is short-lived and the toggle is YAGNI complexity for a tutorial demo. - Drop the agent_settings table. EventBridge rule state is now the source of truth for 'is the loop running'. - loop-start calls EventBridge.EnableRuleCommand; loop-stop calls DisableRuleCommand. A stopped loop actually stops the recurring AWS invocations, not just makes the Lambda a no-op. - Add LOOP_RULE_NAME env var (set by CDK at synth) and events:EnableRule / events:DisableRule IAM on the rule ARN. - Status endpoint unchanged (no loopEnabled field). - Scope: no RAG corpus cap; loop is short-lived in practice. --- .../2026-08-09-loop-mode-poetry-design.md | 130 ++++++++---------- 1 file changed, 57 insertions(+), 73 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index b0664c6..7972a49 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -2,51 +2,47 @@ **Date:** 2026-08-09 **Status:** Draft (pending user review) -**Scope:** Replaces the once-daily schedule with a 3-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + closest-match context, ending with a short haiku), and adds curl commands to start and stop the loop. One writer, dedup made optional via env var, no new user-facing tutorial doc. +**Scope:** Replaces the once-daily schedule with a 3-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + closest-match context, ending with a short haiku), and adds curl commands to start and stop the loop. `loop-stop` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. No dedup, no new user-facing tutorial doc. --- ## 1. Purpose and constraints -The base tutorial posts one Discord message per day, on value change only. For local testing and quick iteration, a 3-minute cadence is more useful — you can see the LLM's output in seconds, not hours — and the message should be more varied (incorporating date, location, and a creative closing) rather than the current template-y "Weather update: 72F" / "Crypto update: 67234.10". +The base tutorial posts one Discord message per day, on value change only. For local testing and quick iteration, a 3-minute cadence is more useful — you can see the LLM's output in seconds, not hours — and the message should be more varied (incorporating date, location, and a creative closing) rather than the current template-y "Weather update: 72F" / "Crypto update: 67234.10". When the test is over, the loop should actually stop running in AWS — no reason to keep a recurring trigger alive when no one is looking at the output. **Constraints carried over from the base spec:** standalone, TypeScript/Node 24/ESM, public-tutorial quality, no VPC/DB server, single-writer invariant, single-user, reader stays read-only. **New constraints this feature must respect:** -- **One writer.** The existing `runFetch` is the only writer path. No parallel `runLoopTick` that forks the semantics. Any loop-specific behavior is a small change inside `runFetch` plus a new enable/disable gate. -- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the curl commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~960 Bedrock calls/day. No new `docs/0X-*.md`. +- **One writer, no dedup.** The existing `runFetch` is the only writer. It always fetches, always formats, always posts. No per-source value-change dedup, no `FETCH_DEDUP` env var, no toggle. The loop is short-lived (minutes to hours) and meant for testing — a missed post is harmless, a silent post-skip is confusing. +- **`loop-stop` actually disables the EventBridge rule.** A stopped loop means no further scheduled invocations, not a Lambda that returns early. Same Lambda, same Function URL, just no trigger firing. `loop-start` re-enables it. +- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the curl commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~960 Bedrock calls/day while running. No new `docs/0X-*.md`. - **Numerology is out.** The closing beat is a short haiku the LLM generates, not a numerology sentence. LLMs produce cliché numerology platitudes; a haiku gives them actual creative room and reads as varied across runs. +- **No RAG corpus cap in this spec.** The RAG invariant "every posted message is searchable" stays intact. The corpus grows at 3-min cadence while the loop runs, which is fine because the loop is short-lived. A future spec could add a cap or vacuum job; that's out of scope here. --- ## 2. Architecture -The deploy changes: -- A new EventBridge schedule at `rate(3 minutes)` replaces the existing `rate(1 day)` schedule (single edit in `infra/stack.ts`). -- The schedule invokes the same Lambda with the same `{op:"fetch"}` payload. No new op for the scheduled tick. +The deploy changes (all in `infra/stack.ts`): +- The existing `FetchSchedule` EventBridge rule is given an explicit `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` (so the handler can refer to it stably), and its schedule is changed from `rate(1 day)` to `rate(3 minutes)`. Same Lambda target, same `{op:"fetch"}` payload. +- The Lambda role gains two new permissions: `events:EnableRule` and `events:DisableRule`, scoped to that rule's ARN. +- The Lambda environment gains two new vars: `LOOP_RULE_NAME` (the rule's full name) and `LOOP_TOKEN` (the gate for the new ops, default-deny if unset, same posture as `FETCH_TRIGGER_TOKEN`). The runtime changes: -- `runFetch` reads two new pieces of state: a `FETCH_DEDUP` env var (default `false`) and a `loop_enabled` setting in the SQLite snapshot (default `true` after first deploy, since the loop is now the primary mode). If `loop_enabled` is `'false'`, `runFetch` returns early without fetching, formatting, or posting. -- When `FETCH_DEDUP` is `false` (the default), the per-source dedup check is skipped — every tick produces one `agent_notifications` row per source and one Discord post per source. When `true`, the existing per-source value-change dedup is preserved (useful if someone wants the original daily-style low-cost behavior). +- `runFetch` is unchanged in structure. It always fetches, always formats, always posts. No dedup-on-unchanged-value check. - The formatter's user prompt gains structured fields: `date`, `location`, `weatherValue`, `cryptoValue`, in addition to the existing `source` and `rawValue`. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, (2) naturally reference the closest past reading if provided, and (3) end with a brief haiku (5-7-5) that incorporates the temperature, the crypto value, and the day's vibe. The control plane changes: -- Two new ops, `loop-start` and `loop-stop`, gated by a new `LOOP_TOKEN` env var (same constant-time check pattern as `FETCH_TRIGGER_TOKEN`). They write `'true'`/`'false'` to a new `agent_settings` row, then re-publish the snapshot with the updated ETag. Default-deny if `LOOP_TOKEN` is unset. -- The status endpoint gains a `loopEnabled: boolean` field so you can see the current state via the existing `op:"status"` call. +- Two new ops, `loop-start` and `loop-stop`, gated by `LOOP_TOKEN` (same constant-time check pattern as `FETCH_TRIGGER_TOKEN`). They call `EventBridge.EnableRuleCommand` / `DisableRuleCommand` on the rule named by `LOOP_RULE_NAME`. They do not touch the SQLite snapshot — EventBridge is the source of truth. They return `{ statusCode: 200, body: JSON.stringify({ loopState: 'ENABLED' | 'DISABLED' }) }` based on the call's result. ``` EventBridge rate(3 min) ──> Lambda (op:"fetch") - │ + │ (rule is ENABLED by default; + │ loop-stop flips it to DISABLED, + │ which stops all further invocations) ▼ runFetch - │ - ├─ read agent_settings.loop_enabled - │ └─ 'false' → return early (no fetch, no post) - │ - ├─ read FETCH_DEDUP env var - │ └─ 'false' (default) → skip dedup check - │ └─ 'true' → keep current dedup behavior │ ├─ per-source: │ fetch, findNearestMatch (RAG), @@ -57,28 +53,16 @@ EventBridge rate(3 min) ──> Lambda (op:"fetch") curl "FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' │ - └─ writes agent_settings.loop_enabled='false', - re-publishes snapshot + └─ EventBridge.DisableRuleCommand( + Name: process.env.LOOP_RULE_NAME) + → rule state = DISABLED, no more ticks ``` --- ## 3. Data model -### 3.1 New table: `agent_settings` - -```sql -CREATE TABLE IF NOT EXISTS agent_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -``` - -One row currently: `(key='loop_enabled', value='true' | 'false')`. The shape is generic (key-value) rather than purpose-built so future settings don't require another schema edit — same pattern the base spec uses for `agent_runs.error` (nullable absence is meaningful, not a placeholder). - -### 3.2 No change to existing tables - -`agent_sources`, `agent_notifications`, `agent_runs`, and `agent_embeddings` are unchanged. `agent_notifications` will grow ~28,800 rows/day at 3-min cadence with `FETCH_DEDUP=false` (2 sources × 480 ticks × ~2 rows/tick counting closest-match bookkeeping), which is a known concern documented in `docs/07-budget-protection.md`. No TTL/cap is added in this scope — adding one would change the RAG "every posted message is searchable" invariant and belongs in a separate spec. +**No schema changes.** `agent_sources`, `agent_notifications`, `agent_runs`, and `agent_embeddings` are unchanged. `agent_notifications` and `agent_embeddings` grow while the loop runs (~960 rows/day each at 3-min cadence with two sources), which is fine because the loop is short-lived. A RAG corpus cap or vacuum job is a future spec, called out as out of scope. --- @@ -86,10 +70,10 @@ One row currently: `(key='loop_enabled', value='true' | 'false')`. The shape is ### 4.1 `src/config.ts` — new env vars -- `FETCH_DEDUP` (string, parsed as `boolean`, default `false`): when `true`, preserves the original per-source value-change dedup; when `false` (default), `runFetch` always posts. -- `LOOP_TOKEN` (string, optional, default `null`): gates the new `loop-start` / `loop-stop` ops. Same default-deny posture as `FETCH_TRIGGER_TOKEN`. +- `LOOP_TOKEN` (string, optional, default `null`): gates the new `loop-start` / `loop-stop` ops. Same default-deny posture as `FETCH_TRIGGER_TOKEN` (rejects with 403 when unset). +- `LOOP_RULE_NAME` (string, required if `LOOP_TOKEN` is set, default `null`): the EventBridge rule to enable/disable. Set by the CDK stack at synth time. -Both reuse the existing `str` / `optionalStr` / `num` helpers — no new parser. +No new parser — both reuse the existing `str` / `optionalStr` helpers. ### 4.2 `src/format/types.ts` — new `FormatContext` @@ -129,26 +113,28 @@ with the message text only — no quotes, no preamble, no markdown. The local template formatter is updated to a minimal `{date} — {weatherValue} / {cryptoValue} / ` shape so its tests still pin the new context fields. It is not expected to generate a haiku — it's a test-only stub. -### 4.4 `src/agent/fetch.ts` — loop gate + dedup toggle - -- Reads `loop_enabled` from `agent_settings` at the start. If `'false'`, returns `{ outcome: 'success', sourcesChecked: 0, notificationsSent: 0, error: null }` without writing a run row, fetching, or publishing (so a stopped loop is a no-op every tick, not a record-keeping churn). -- Reads `FETCH_DEDUP` via `loadConfig`. If `false`, the per-source `rawValue === lastValue` check is skipped; every source is formatted and posted unconditionally. -- The per-source loop body (fetch, RAG lookup, format, post, insert, embed) is otherwise unchanged. +### 4.4 `src/agent/fetch.ts` — fetch all four values, no dedup -### 4.5 `src/handler.ts` — new ops +The writer is structurally unchanged but now: +- Fetches all four values up front (weather, crypto, plus the location from config and the date from `now()`) so the formatter can receive them in a single `FormatContext` per source. +- Per-source iteration: no dedup check. Every source is formatted and posted every tick. +- Per-source format call receives a full `FormatContext` populated from the up-front fetches plus the per-source `rawValue` and `similarPast` lookup. -- `op === 'loop-start'` / `op === 'loop-stop'`: same `requestContext`-gated token check as the existing `fetch` trigger. On success, write to `agent_settings` via a new `setSetting(db, key, value)` helper in `src/db/settings.ts`, then publish the snapshot with the current ETag (matching `runFetch`'s conditional-write pattern). Return `{ statusCode: 200, body: JSON.stringify({ loopEnabled: }) }`. -- The `status` op gains a `loopEnabled` field in `StatusResult`, populated from `agent_settings` with a default of `true` if the row is missing (e.g., a snapshot from before this feature shipped). +### 4.5 `src/handler.ts` — new ops, EventBridge client -### 4.6 `src/agent/status.ts` — expose `loopEnabled` +- Adds an `EventBridgeClient` to the `InjectedClients` interface (with a default constructed from `config.region`), constructed once per invocation like the existing `S3Client` and `BedrockRuntimeClient`. +- `op === 'loop-start'`: token check → `EnableRuleCommand({ Name: config.loopRuleName })` → return `{ loopState: 'ENABLED' }`. On error, return `{ statusCode: 500, body: JSON.stringify({ error: ... }) }`. +- `op === 'loop-stop'`: token check → `DisableRuleCommand({ Name: config.loopRuleName })` → return `{ loopState: 'DISABLED' }`. Same error shape. +- The `status` op is unchanged (no new fields). -Reads the setting on every `getStatus` call. The reader already reads from the snapshot, so this is a small additive change to the SQL. +### 4.6 `infra/stack.ts` — schedule, env, IAM -### 4.7 `infra/stack.ts` — schedule replacement +- Set `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` on the `FetchSchedule` rule. +- Change `schedule: events.Schedule.rate(cdk.Duration.days(1))` to `events.Schedule.rate(cdk.Duration.minutes(3))`. +- Add `LOOP_RULE_NAME: ` and conditional `LOOP_TOKEN: ` to the Lambda environment. +- Add a new IAM `PolicyStatement`: `events:EnableRule`, `events:DisableRule` on `rule.ruleArn`. The `retryAttempts: 0` and `RuleTargetInput.fromObject({ op: 'fetch' })` already in place stay. -Replace the existing `rate(1 day)` schedule with `rate(3 minutes)`. Single line change. No new infra (no DLQ, no alarm — the loop is for local testing, not unattended operation; this is called out in the README). - -### 4.8 `scripts/loop-start.sh` / `scripts/loop-stop.sh` +### 4.7 `scripts/loop-start.sh` / `scripts/loop-stop.sh` Two new scripts matching the style of `scripts/smoke.sh`: tempfiles for sensitive inputs (the URL, the token), `curl` with the right op, parse the JSON response, print success/failure. Both expect `FUNCTION_URL` and `LOOP_TOKEN` in the environment (sourced from `.env.discord` like the existing smoke script). @@ -158,22 +144,20 @@ Two new scripts matching the style of `scripts/smoke.sh`: tempfiles for sensitiv | Scenario | Before | After | |---|---|---| -| Daily fetch at 1 day | One fetch per day, dedup on | Still works if `FETCH_DEDUP=true`; the schedule is the only change at the infra layer | +| Daily fetch at 1 day | One fetch per day, dedup on | Schedule changed to 3 min, no dedup. Same `op:"fetch"`, same Lambda. | | Loop at 3 min | n/a | Two Discord messages per tick (one weather, one crypto) | | Message content | "Weather update: 72F" | Date, location, weather, crypto, optional closest-past reference, ends with a haiku | -| Dedup on value change | Always on | Off by default; opt-in via `FETCH_DEDUP=true` | -| Stop the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-stop"}'`. Loop ticks return early; no fetch, no post, no S3 publish. | -| Start the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-start"}'`. Subsequent ticks post as normal. | -| Status endpoint | snapshotVersion, sources, recentNotifications | Adds `loopEnabled: boolean` | +| Dedup on value change | Always on | Removed. Writer always posts. | +| Stop the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-stop"}'`. EventBridge rule state = DISABLED. No further scheduled invocations. | +| Start the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-start"}'`. Rule state = ENABLED. Subsequent ticks post as normal. | +| Status endpoint | snapshotVersion, sources, recentNotifications | Unchanged | --- ## 6. Error handling -Loop-specific failures follow the same isolation rules as the base spec: - -- A `loop-start` / `loop-stop` op that fails the conditional write loses the setting change: the local DB has the new value, but the next invocation hydrates from S3 (which still has the old snapshot) and the change is not visible. The same posture as the existing `runFetch` publish-failure path (base spec §4.2). The user must re-run the curl; the failed attempt is logged in-process but not visible to a fresh reader. -- A `runFetch` invocation that runs while `loop_enabled='false'` is a no-op. It does not write an `agent_runs` row (so the table is not filled with empty rows at 3-min cadence), and it does not republish the snapshot (so the S3 object is not touched). +- A `loop-start` / `loop-stop` op that fails the EventBridge API call returns 500 with the error message. The DB snapshot is not touched (EventBridge is the source of truth, not the snapshot). +- A `runFetch` invocation that fails the EventBridge call is not a concern — `runFetch` is invoked *by* EventBridge, not the other way around. The `events:DisableRule` call simply means EventBridge stops calling `runFetch`. - A formatter error per source is caught by the existing per-source `try`/`catch` and folded into `agent_runs.error`. The other source still posts. - A Discord 4xx is not retried (existing behavior); a 5xx gets one ~250ms retry (existing). Same rules apply. @@ -183,15 +167,15 @@ Loop-specific failures follow the same isolation rules as the base spec: Unit tests (vitest) for: -- `runFetch` with `loop_enabled='false'`: short-circuits, no fetch, no post, no publish, no run row. -- `runFetch` with `FETCH_DEDUP=false`: two unchanged-value ticks both post. -- `runFetch` with `FETCH_DEDUP=true`: an unchanged-value tick is deduped (regression test for the existing dedup path, which is now opt-in). -- `loop-start` / `loop-stop` ops: write to `agent_settings`, publish the snapshot, return the new `loopEnabled` value. -- `loop-start` / `loop-stop` without `LOOP_TOKEN`: 403, no DB write, no publish. -- Status endpoint: `loopEnabled` reflects the current setting (true / false / missing → default true). +- `runFetch` always posts (one run, both sources' values present, two `agent_notifications` rows, two Discord posts, no dedup-skip). +- `runFetch` with both sources fetched the same value as last time: both still post (regression test for the removed dedup path). +- `loop-start` op with valid `LOOP_TOKEN`: calls `EventBridge.EnableRuleCommand` with the rule name from env, returns 200 `{loopState: 'ENABLED'}`. +- `loop-stop` op with valid `LOOP_TOKEN`: calls `EventBridge.DisableRuleCommand`, returns 200 `{loopState: 'DISABLED'}`. +- `loop-start` / `loop-stop` without `LOOP_TOKEN`: 403, no EventBridge call. - Formatter receives the new `FormatContext` shape and the new fields are present in the Bedrock request body (via the existing `aws-sdk-client-mock` ConverseCommand matcher). +- Existing RAG tests: unchanged. `findNearestMatch` still works against the growing corpus. -The RAG corpus-bloat concern is called out in `docs/07-budget-protection.md` as a known limitation; no automated test for it (it would require running the loop for hours to measure). +No tests for the RAG corpus-bloat rate (would require running the loop for hours to measure); called out as a known characteristic in the budget note, not a testable invariant. --- @@ -199,13 +183,13 @@ The RAG corpus-bloat concern is called out in `docs/07-budget-protection.md` as Per the user instruction, no new `docs/0X-*.md` tutorial file. Two small changes: -- **`README.md`** — new "Loop mode" subsection under "Quick start" with the curl commands for `loop-start` / `loop-stop`, the env vars (`FETCH_DEDUP`, `LOOP_TOKEN`), and a one-line note that the loop is for local testing. -- **`docs/07-budget-protection.md`** — one paragraph added: with `FETCH_DEDUP=false` at 3-min cadence, expect ~960 Bedrock calls/day (2 sources × 480 ticks). At default model pricing, this is roughly $0.05–$0.10/day vs ~$0.02/year for the once-daily fetch. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~28,800 rows/day; if left running unattended this is the primary cost driver, not the Bedrock calls themselves. +- **`README.md`** — new "Loop mode" subsection under "Quick start" with the curl commands for `loop-start` / `loop-stop`, the env vars (`LOOP_TOKEN`, `LOOP_RULE_NAME`), and a one-line note that the loop is for local testing and should be stopped when done. +- **`docs/07-budget-protection.md`** — one paragraph added: with the loop running at 3-min cadence, expect ~960 Bedrock calls/day (2 sources × 480 ticks). At default model pricing, this is roughly $0.05–$0.10/day. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~28,800 rows/day; while the loop is short-lived this is fine. `loop-stop` disables the EventBridge rule, which is the way to actually shut down the recurring cost. --- ## 9. Open concerns (out of scope for this spec) -- **RAG corpus bloat.** At 3-min cadence with `FETCH_DEDUP=false`, `agent_notifications` and `agent_embeddings` grow by ~28,800 rows/day each. The reader and RAG KNN queries still work, but the SQLite file gets larger and S3 storage cost grows linearly. A future spec could add a TTL or a sliding-window cap; this spec does not. -- **Loop token rotation.** `LOOP_TOKEN` is an env var set at deploy time; rotating it requires a redeploy. Same posture as `FETCH_TRIGGER_TOKEN`, called out here for consistency. -- **Unattended operation.** The loop is intended for local testing, not for being left on. The README's "Loop mode" subsection should explicitly say "stop the loop when you're done." +- **RAG corpus bloat over long runs.** If the loop is ever left running for days, `agent_notifications` and `agent_embeddings` grow linearly. A future spec could add a sliding-window cap or a vacuum job. This spec keeps the "every posted message is searchable forever" RAG invariant intact and relies on the short-lived nature of the loop in practice. +- **Loop token rotation.** `LOOP_TOKEN` is an env var set at deploy time; rotating it requires a redeploy. Same posture as `FETCH_TRIGGER_TOKEN`. +- **Unattended operation.** The loop is intended for local testing, not for being left on. The README's "Loop mode" subsection should explicitly say "stop the loop when you're done — `loop-stop` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops." From c1749a319ae54d65ce700f16186bf3481557ced8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:14:13 -0400 Subject: [PATCH 04/26] fix(spec): require LOOP_RULE_NAME whenever LOOP_TOKEN is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two env vars travel together — the CDK stack sets both or neither. Add a single cross-field check in loadConfig so a half-configured deploy fails at startup rather than at the first loop-start/loop-stop call. --- docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index 7972a49..634e1f6 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -71,9 +71,9 @@ curl "FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' ### 4.1 `src/config.ts` — new env vars - `LOOP_TOKEN` (string, optional, default `null`): gates the new `loop-start` / `loop-stop` ops. Same default-deny posture as `FETCH_TRIGGER_TOKEN` (rejects with 403 when unset). -- `LOOP_RULE_NAME` (string, required if `LOOP_TOKEN` is set, default `null`): the EventBridge rule to enable/disable. Set by the CDK stack at synth time. +- `LOOP_RULE_NAME` (string, optional, default `null`): the EventBridge rule to enable/disable. If `LOOP_TOKEN` is set but `LOOP_RULE_NAME` is empty, `loadConfig` throws at startup — the two env vars travel together (the CDK stack sets both or neither). Set by the CDK stack at synth time. -No new parser — both reuse the existing `str` / `optionalStr` helpers. +No new parser — both reuse the existing `str` / `optionalStr` helpers, with `loadConfig` adding a single cross-field check. ### 4.2 `src/format/types.ts` — new `FormatContext` From 171c6cc1f87f38391e1073a4e5057d98c686efe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:19:50 -0400 Subject: [PATCH 05/26] fix(spec): one combined message per tick; auto-generate LOOP_TOKEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure runFetch: fetch all sources up front, run per-source RAG lookups, format ONCE with a combined LoopContext, post ONCE, then write per-source agent_notifications rows (each carrying the same combined formatted_message). One Discord message per tick. - LoopContext replaces the per-source FormatContext; carries weatherValue + cryptoValue + per-source similarPast pairs. The SimilarPastResult interface gains notificationId and distance so the writer can populate the per-source nearest_match columns without a second RAG lookup. - LOOP_TOKEN is auto-generated at synth time (crypto.randomBytes) and exposed as a CloudFormation output. The start/stop scripts read FUNCTION_URL and LOOP_TOKEN from stack outputs, so the user just runs the script — no secret to copy. - Budget note updated: ~480 Bedrock calls/day (1 post per tick instead of 2 per source). - Open concerns section notes the per-source RAG behavior when one source fails. --- .../2026-08-09-loop-mode-poetry-design.md | 182 ++++++++++++------ 1 file changed, 119 insertions(+), 63 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index 634e1f6..5c4a241 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -2,21 +2,22 @@ **Date:** 2026-08-09 **Status:** Draft (pending user review) -**Scope:** Replaces the once-daily schedule with a 3-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + closest-match context, ending with a short haiku), and adds curl commands to start and stop the loop. `loop-stop` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. No dedup, no new user-facing tutorial doc. +**Scope:** Replaces the once-daily schedule with a 3-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + closest-match context, ending with a short haiku), and adds commands to start and stop the loop. `loop-stop` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. One Discord message per tick (combined weather + crypto + haiku), no dedup, no new user-facing tutorial doc. The loop token is auto-generated by the CDK stack at synth time so the user doesn't have to manage a secret. --- ## 1. Purpose and constraints -The base tutorial posts one Discord message per day, on value change only. For local testing and quick iteration, a 3-minute cadence is more useful — you can see the LLM's output in seconds, not hours — and the message should be more varied (incorporating date, location, and a creative closing) rather than the current template-y "Weather update: 72F" / "Crypto update: 67234.10". When the test is over, the loop should actually stop running in AWS — no reason to keep a recurring trigger alive when no one is looking at the output. +The base tutorial posts one Discord message per day, on value change only. For local testing and quick iteration, a 3-minute cadence is more useful — you can see the LLM's output in seconds, not hours — and the message should be more varied (incorporating date, location, and a creative closing) rather than the current template-y "Weather update: 72F" / "Crypto update: 67234.10". When the test is over, the loop should actually stop running in AWS — no reason to keep a recurring trigger alive when no one is looking at the output. The control plane (start/stop) should be a single curl, with no secret to set up by hand. **Constraints carried over from the base spec:** standalone, TypeScript/Node 24/ESM, public-tutorial quality, no VPC/DB server, single-writer invariant, single-user, reader stays read-only. **New constraints this feature must respect:** -- **One writer, no dedup.** The existing `runFetch` is the only writer. It always fetches, always formats, always posts. No per-source value-change dedup, no `FETCH_DEDUP` env var, no toggle. The loop is short-lived (minutes to hours) and meant for testing — a missed post is harmless, a silent post-skip is confusing. +- **One writer, no dedup, one message per tick.** The existing `runFetch` is the only writer. It always fetches all sources, always formats ONCE with a combined context, always posts ONCE. The Discord channel gets one message per tick, not two. Per-source `agent_notifications` rows are still written (one per source per tick) so the status endpoint and RAG corpus work unchanged, but they all carry the same combined `formatted_message`. No per-source value-change dedup, no `FETCH_DEDUP` env var, no toggle. The loop is short-lived (minutes to hours) and meant for testing — a missed post is harmless, a silent post-skip is confusing. - **`loop-stop` actually disables the EventBridge rule.** A stopped loop means no further scheduled invocations, not a Lambda that returns early. Same Lambda, same Function URL, just no trigger firing. `loop-start` re-enables it. -- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the curl commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~960 Bedrock calls/day while running. No new `docs/0X-*.md`. +- **Loop token is auto-generated, not user-set.** The CDK stack generates `LOOP_TOKEN` at synth time using a secure RNG, passes it to the Lambda env, and exposes it as a stack output. The `loop-start.sh` / `loop-stop.sh` scripts read the token from stack outputs at runtime — the user just runs the script, no `export LOOP_TOKEN=...` required. +- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the curl/script commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~480 Bedrock calls/day while running. No new `docs/0X-*.md`. - **Numerology is out.** The closing beat is a short haiku the LLM generates, not a numerology sentence. LLMs produce cliché numerology platitudes; a haiku gives them actual creative room and reads as varied across runs. - **No RAG corpus cap in this spec.** The RAG invariant "every posted message is searchable" stays intact. The corpus grows at 3-min cadence while the loop runs, which is fine because the loop is short-lived. A future spec could add a cap or vacuum job; that's out of scope here. @@ -27,14 +28,15 @@ The base tutorial posts one Discord message per day, on value change only. For l The deploy changes (all in `infra/stack.ts`): - The existing `FetchSchedule` EventBridge rule is given an explicit `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` (so the handler can refer to it stably), and its schedule is changed from `rate(1 day)` to `rate(3 minutes)`. Same Lambda target, same `{op:"fetch"}` payload. - The Lambda role gains two new permissions: `events:EnableRule` and `events:DisableRule`, scoped to that rule's ARN. -- The Lambda environment gains two new vars: `LOOP_RULE_NAME` (the rule's full name) and `LOOP_TOKEN` (the gate for the new ops, default-deny if unset, same posture as `FETCH_TRIGGER_TOKEN`). +- The Lambda environment gains `LOOP_RULE_NAME` (the rule's full name) and `LOOP_TOKEN` (a 24-byte hex value generated at synth time via `crypto.randomBytes(24).toString('hex')`). Both are also exposed as CloudFormation outputs. The runtime changes: -- `runFetch` is unchanged in structure. It always fetches, always formats, always posts. No dedup-on-unchanged-value check. -- The formatter's user prompt gains structured fields: `date`, `location`, `weatherValue`, `cryptoValue`, in addition to the existing `source` and `rawValue`. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, (2) naturally reference the closest past reading if provided, and (3) end with a brief haiku (5-7-5) that incorporates the temperature, the crypto value, and the day's vibe. +- `runFetch` is restructured to the combined-message flow. It fetches all sources up front, runs the per-source RAG lookup for each (keyed on the per-source value), then calls the formatter ONCE with a combined `LoopContext`, posts ONCE, and writes per-source `agent_notifications` rows (each with the same combined `formatted_message`) plus a per-source `agent_embeddings` row (each embedding the same combined message text). The combined Discord post is the only user-visible output of a tick. +- The formatter's user prompt gains structured fields: `date`, `location`, `weatherValue`, `cryptoValue`, plus per-source closest-match pairs. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, (2) naturally reference one or both closest-past readings if provided, and (3) end with a brief haiku (5-7-5) that incorporates the temperature, the crypto value, and the day's vibe. The control plane changes: - Two new ops, `loop-start` and `loop-stop`, gated by `LOOP_TOKEN` (same constant-time check pattern as `FETCH_TRIGGER_TOKEN`). They call `EventBridge.EnableRuleCommand` / `DisableRuleCommand` on the rule named by `LOOP_RULE_NAME`. They do not touch the SQLite snapshot — EventBridge is the source of truth. They return `{ statusCode: 200, body: JSON.stringify({ loopState: 'ENABLED' | 'DISABLED' }) }` based on the call's result. +- The `loop-start.sh` / `loop-stop.sh` scripts read `FUNCTION_URL` and `LOOP_TOKEN` from stack outputs (matching the existing `smoke.sh` pattern), curl the right op, and print success/failure. ``` EventBridge rate(3 min) ──> Lambda (op:"fetch") @@ -44,25 +46,43 @@ EventBridge rate(3 min) ──> Lambda (op:"fetch") ▼ runFetch │ - ├─ per-source: - │ fetch, findNearestMatch (RAG), - │ formatter.format(ctx), post to Discord, - │ insert agent_notifications row, embed + store + ├─ fetch all sources up front (per-source try/catch + │ isolates failures; failed sources are absent + │ from the context but the others still post) + │ + ├─ per-source RAG lookup: embed each value, find + │ nearest past same-source notification + │ + ├─ formatter.format(LoopContext) ← ONE call + │ + ├─ poster.post(formatted) ← ONE post + │ + ├─ per-source: insert agent_notifications row + │ (same formatted_message, per-source value, + │ per-source nearest_match), embed combined + │ message and store under that source label │ └─ publish snapshot to S3 (conditional) -curl "FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' - │ - └─ EventBridge.DisableRuleCommand( - Name: process.env.LOOP_RULE_NAME) - → rule state = DISABLED, no more ticks +scripts/loop-stop.sh: + FUNCTION_URL=$(aws cloudformation describe-stacks --query "..." AgentFunctionUrl) + LOOP_TOKEN=$(aws cloudformation describe-stacks --query "..." LoopToken) + curl -X POST "$FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' + └─ EventBridge.DisableRuleCommand(Name: LOOP_RULE_NAME) + → rule state = DISABLED, no more ticks ``` --- ## 3. Data model -**No schema changes.** `agent_sources`, `agent_notifications`, `agent_runs`, and `agent_embeddings` are unchanged. `agent_notifications` and `agent_embeddings` grow while the loop runs (~960 rows/day each at 3-min cadence with two sources), which is fine because the loop is short-lived. A RAG corpus cap or vacuum job is a future spec, called out as out of scope. +**No schema changes.** `agent_sources`, `agent_notifications`, `agent_runs`, and `agent_embeddings` are unchanged. + +`agent_notifications` gains one row per source per tick (so ~960 rows/day at 3-min cadence). All rows from the same tick carry the same `formatted_message` (the combined Discord post) and the same `posted_at`. The `value` column is the per-source value, and `nearest_match_id` / `nearest_match_distance` are the per-source RAG match. This preserves the existing schema and query patterns (status endpoint, RAG KNN) without modification. + +`agent_embeddings` gains one row per source per tick (the embedding of the combined message, indexed under each source label). Two rows for the same text is a small redundancy but lets the per-source KNN query keep working unchanged. + +Both grow at the same rate as before — the loop is short-lived so the absolute size stays small. A RAG corpus cap or vacuum job is a future spec, called out as out of scope. --- @@ -70,55 +90,82 @@ curl "FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' ### 4.1 `src/config.ts` — new env vars -- `LOOP_TOKEN` (string, optional, default `null`): gates the new `loop-start` / `loop-stop` ops. Same default-deny posture as `FETCH_TRIGGER_TOKEN` (rejects with 403 when unset). -- `LOOP_RULE_NAME` (string, optional, default `null`): the EventBridge rule to enable/disable. If `LOOP_TOKEN` is set but `LOOP_RULE_NAME` is empty, `loadConfig` throws at startup — the two env vars travel together (the CDK stack sets both or neither). Set by the CDK stack at synth time. +- `LOOP_TOKEN` (string, required for `loop-start` / `loop-stop` to be reachable, default `null`): gates the new ops. If unset, the handler returns 403 for both ops (default-deny, same posture as `FETCH_TRIGGER_TOKEN`). +- `LOOP_RULE_NAME` (string, optional, default `null`): the EventBridge rule to enable/disable. If `LOOP_TOKEN` is set but `LOOP_RULE_NAME` is empty, `loadConfig` throws at startup. Set by the CDK stack. -No new parser — both reuse the existing `str` / `optionalStr` helpers, with `loadConfig` adding a single cross-field check. +In practice, the CDK stack always sets both. End users see them only as stack outputs. -### 4.2 `src/format/types.ts` — new `FormatContext` +### 4.2 `src/format/types.ts` — new `LoopContext` ```typescript -export interface FormatContext { - source: SourceName; - rawValue: string; - date: string; // ISO date, e.g. "2026-08-09" - location: string; // e.g. "NYC" - weatherValue: string; // current weather for the location, e.g. "72F" - cryptoValue: string; // current BTC USD price as a string, e.g. "67234.10" - similarPast: SimilarPastResult | null; +export interface SimilarPastResult { + formattedMessage: string; + postedAt: number; + /** The notification id and distance of the closest same-source past notification + * (RAG design spec §7). Carried alongside the formatted message so the writer + * can populate the per-source `nearest_match_id` / `nearest_match_distance` + * columns on `agent_notifications`. */ + notificationId: number; + distance: number; +} + +export interface LoopContext { + date: string; // ISO date, e.g. "2026-08-09" + location: string; // e.g. "NYC" + weatherValue: string; // current weather for the location, e.g. "72F" + cryptoValue: string; // current BTC USD price as a string, e.g. "67234.10" + /** Per-source closest past reading (the past combined message that was posted + * when a similar value was observed). Either may be null if the source has + * no history yet or the RAG lookup failed and was isolated. */ + similarPast: { + weather: SimilarPastResult | null; + crypto: SimilarPastResult | null; + }; } export interface MessageFormatter { - format(ctx: FormatContext): Promise; + format(ctx: LoopContext): Promise; } ``` The signature change is internal — `MessageFormatter` is a TypeScript type used only by `runFetch` and `localFetch` (and the format module's own tests); it is not a public package surface. `LocalTemplateFormatter` and `BedrockFormatter` are updated to match. +`SimilarPastResult` is the per-source closest-match record. Previously it was a thin `{ formattedMessage, postedAt }`; it now also carries `notificationId` and `distance` so the writer can populate the corresponding columns on `agent_notifications` without a second RAG lookup. The existing per-source `format()` callers in tests that build a `SimilarPastResult` get a `notificationId: 0, distance: 0` stub — those tests don't care about RAG, only the formatter's behavior. + ### 4.3 `src/format/bedrock.ts` — new system prompt ``` SYSTEM: You write a short, friendly Discord message for a daily-checkin bot that -posts a weather and crypto snapshot every few minutes. The user message -below contains today's date, the location, the current weather, the current -crypto value, and (when available) the closest past reading. Write a brief -comment (one or two sentences) that draws on these inputs — vary your -phrasing across runs; do not repeat the same template. If a closest past -reading is included, you may naturally reference it, but you are not -required to. End with a short haiku (three lines, 5-7-5 syllables) that -weaves in the temperature, the crypto value, and the day's vibe. Reply -with the message text only — no quotes, no preamble, no markdown. +posts a combined weather and crypto snapshot every few minutes. The user +message below contains today's date, the location, the current weather, +the current crypto value, and (when available) the closest past reading +for each. Write a brief comment (one or two sentences) that draws on +these inputs — vary your phrasing across runs; do not repeat the same +template. If one or both closest-past readings are included, you may +naturally reference them, but you are not required to. End with a short +haiku (three lines, 5-7-5 syllables) that weaves in the temperature, the +crypto value, and the day's vibe. Reply with the message text only — no +quotes, no preamble, no markdown. ``` -The local template formatter is updated to a minimal `{date} — {weatherValue} / {cryptoValue} / ` shape so its tests still pin the new context fields. It is not expected to generate a haiku — it's a test-only stub. +The local template formatter is updated to a minimal `"{date} — {weatherValue} / {cryptoValue}"` shape so its tests still pin the new context fields. It is not expected to generate a haiku — it's a test-only stub. + +### 4.4 `src/agent/fetch.ts` — combined-message writer -### 4.4 `src/agent/fetch.ts` — fetch all four values, no dedup +The writer is restructured from "per-source format+post loop" to "fetch all, format once, post once, write per-source rows." -The writer is structurally unchanged but now: -- Fetches all four values up front (weather, crypto, plus the location from config and the date from `now()`) so the formatter can receive them in a single `FormatContext` per source. -- Per-source iteration: no dedup check. Every source is formatted and posted every tick. -- Per-source format call receives a full `FormatContext` populated from the up-front fetches plus the per-source `rawValue` and `similarPast` lookup. +New flow: + +1. Hydrate, open, bootstrap, start the `agent_runs` row (unchanged). +2. For each source, call `source.fetch()` and collect results into a per-source map. Per-source failures are caught and folded into `errors`; the source is omitted from the map. +3. For each successful source, call `params.embedder.embed(rawValue)` and `findNearestMatch(db, sourceName, vector)` to populate the per-source `similarPast` map. Per-source embed/lookup failures are caught and folded into `errors`; the corresponding `similarPast` entry is `null`. +4. If the per-source map is non-empty, build a `LoopContext` with `date = ISO slice of now()`, `location = config.weatherLocation`, `weatherValue` / `cryptoValue` from the map (or `''` if a source failed), and `similarPast` from the RAG map. Call `params.formatter.format(ctx)` and `params.poster.post(formatted)`. If format or post throws, fold the error into `errors`; no per-source rows are written for this tick. +5. For each source in the map, insert one `agent_notifications` row with the per-source `value`, the combined `formatted_message`, the per-source `nearest_match_id` / `nearest_match_distance`, and the shared `posted_at`. Update `agent_sources` with the per-source last-value/last-fetched/last-posted timestamps. Embed the combined `formatted_message` and `insertEmbedding(db, notificationId, vector)`. Per-source embed/store failures are caught and folded into `errors`; the row stays. +6. Finish the `agent_runs` row with `notificationsSent: 1` (one combined post per tick, regardless of how many sources contributed) or `0` if step 4 was skipped. +7. Publish the snapshot (unchanged). + +The `runFetch` signature gains `weatherLocation: string` (so the writer can populate the context's `location` field) and a `now: () => number` is already injected; `date` is derived from `now()` in step 4. The result shape `RunFetchResult` is unchanged; `notificationsSent` is now `0 | 1` per tick. ### 4.5 `src/handler.ts` — new ops, EventBridge client @@ -127,16 +174,19 @@ The writer is structurally unchanged but now: - `op === 'loop-stop'`: token check → `DisableRuleCommand({ Name: config.loopRuleName })` → return `{ loopState: 'DISABLED' }`. Same error shape. - The `status` op is unchanged (no new fields). -### 4.6 `infra/stack.ts` — schedule, env, IAM +### 4.6 `infra/stack.ts` — schedule, env, IAM, output - Set `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` on the `FetchSchedule` rule. - Change `schedule: events.Schedule.rate(cdk.Duration.days(1))` to `events.Schedule.rate(cdk.Duration.minutes(3))`. -- Add `LOOP_RULE_NAME: ` and conditional `LOOP_TOKEN: ` to the Lambda environment. +- Add `LOOP_RULE_NAME: rule.ruleName` and `LOOP_TOKEN: randomBytes(24).toString('hex')` to the Lambda environment. The token is generated at synth time and stays stable for the life of the stack. - Add a new IAM `PolicyStatement`: `events:EnableRule`, `events:DisableRule` on `rule.ruleArn`. The `retryAttempts: 0` and `RuleTargetInput.fromObject({ op: 'fetch' })` already in place stay. +- Add two new `CfnOutput`: `LoopToken` (the token) and `LoopRuleName` (the rule's name). The token output is what the start/stop scripts read. ### 4.7 `scripts/loop-start.sh` / `scripts/loop-stop.sh` -Two new scripts matching the style of `scripts/smoke.sh`: tempfiles for sensitive inputs (the URL, the token), `curl` with the right op, parse the JSON response, print success/failure. Both expect `FUNCTION_URL` and `LOOP_TOKEN` in the environment (sourced from `.env.discord` like the existing smoke script). +Two new scripts matching the style of `scripts/smoke.sh`. Both read `FUNCTION_URL` and `LOOP_TOKEN` from the stack's CloudFormation outputs (same `aws cloudformation describe-stacks` + `jq` pattern as `smoke.sh` reads `AgentFunctionName` and `AgentFunctionUrl`), then curl the right op. The user just runs `./scripts/loop-stop.sh` — no env vars to set, no token to copy, no auth to configure beyond the AWS CLI credentials the smoke script already needs. + +The scripts also print the rule's current state on success so the user can see the toggle took effect. --- @@ -145,20 +195,23 @@ Two new scripts matching the style of `scripts/smoke.sh`: tempfiles for sensitiv | Scenario | Before | After | |---|---|---| | Daily fetch at 1 day | One fetch per day, dedup on | Schedule changed to 3 min, no dedup. Same `op:"fetch"`, same Lambda. | -| Loop at 3 min | n/a | Two Discord messages per tick (one weather, one crypto) | -| Message content | "Weather update: 72F" | Date, location, weather, crypto, optional closest-past reference, ends with a haiku | +| Loop at 3 min | n/a | One Discord message per tick (combined weather + crypto + haiku). Per-source `agent_notifications` rows still written. | +| Message content | "Weather update: 72F" / "Crypto update: 67234.10" (one per source) | One message per tick: date, location, weather, crypto, optional closest-past references, ends with a haiku | | Dedup on value change | Always on | Removed. Writer always posts. | -| Stop the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-stop"}'`. EventBridge rule state = DISABLED. No further scheduled invocations. | -| Start the loop | n/a | `curl $FUNCTION_URL?token=$LOOP_TOKEN --data '{"op":"loop-start"}'`. Rule state = ENABLED. Subsequent ticks post as normal. | +| Stop the loop | n/a | `./scripts/loop-stop.sh`. EventBridge rule state = DISABLED. No further scheduled invocations. | +| Start the loop | n/a | `./scripts/loop-start.sh`. Rule state = ENABLED. Subsequent ticks post as normal. | | Status endpoint | snapshotVersion, sources, recentNotifications | Unchanged | +| Loop token | n/a | Auto-generated by CDK at synth, exposed as `LoopToken` stack output. Scripts read it; user never copies a secret. | --- ## 6. Error handling - A `loop-start` / `loop-stop` op that fails the EventBridge API call returns 500 with the error message. The DB snapshot is not touched (EventBridge is the source of truth, not the snapshot). -- A `runFetch` invocation that fails the EventBridge call is not a concern — `runFetch` is invoked *by* EventBridge, not the other way around. The `events:DisableRule` call simply means EventBridge stops calling `runFetch`. -- A formatter error per source is caught by the existing per-source `try`/`catch` and folded into `agent_runs.error`. The other source still posts. +- A `runFetch` invocation that fails because the EventBridge call disabled the rule is not a concern — `runFetch` is invoked *by* EventBridge, not the other way around. The `events:DisableRule` call simply means EventBridge stops calling `runFetch`. +- A per-source fetch failure is caught in step 2 and folded into `errors`. The other sources still contribute to the combined message. `agent_sources` is not updated for the failed source (no `last_value` written). +- A formatter or post failure in step 4 is caught and folded into `errors`. No per-source `agent_notifications` rows are written for this tick; the run still completes. +- A per-source embed/insert failure in step 5 is caught and folded into `errors`. The notification row stays; only the RAG corpus fails to grow by this entry. - A Discord 4xx is not retried (existing behavior); a 5xx gets one ~250ms retry (existing). Same rules apply. --- @@ -167,13 +220,16 @@ Two new scripts matching the style of `scripts/smoke.sh`: tempfiles for sensitiv Unit tests (vitest) for: -- `runFetch` always posts (one run, both sources' values present, two `agent_notifications` rows, two Discord posts, no dedup-skip). -- `runFetch` with both sources fetched the same value as last time: both still post (regression test for the removed dedup path). -- `loop-start` op with valid `LOOP_TOKEN`: calls `EventBridge.EnableRuleCommand` with the rule name from env, returns 200 `{loopState: 'ENABLED'}`. +- `runFetch` happy path: both sources succeed, formatter is called ONCE with a combined `LoopContext`, Discord is posted ONCE, two `agent_notifications` rows are written (one per source) with the same `formatted_message`, two `agent_embeddings` rows are inserted, snapshot is published. +- `runFetch` with one source failing (e.g., coingecko down): the other source still contributes; formatter receives `weatherValue: '72F'`, `cryptoValue: ''`; one notification row; `errors` includes the failed source. +- `runFetch` with both sources failing: formatter is not called, no Discord post, no notification rows, `errors` includes both sources, `notificationsSent: 0`. +- `runFetch` formatter failure: caught and folded into `errors`, no notification rows, no Discord post, snapshot is still published. +- `loop-start` op with valid `LOOP_TOKEN`: calls `EventBridge.EnableRuleCommand` with `LOOP_RULE_NAME`, returns 200 `{loopState: 'ENABLED'}`. - `loop-stop` op with valid `LOOP_TOKEN`: calls `EventBridge.DisableRuleCommand`, returns 200 `{loopState: 'DISABLED'}`. - `loop-start` / `loop-stop` without `LOOP_TOKEN`: 403, no EventBridge call. -- Formatter receives the new `FormatContext` shape and the new fields are present in the Bedrock request body (via the existing `aws-sdk-client-mock` ConverseCommand matcher). -- Existing RAG tests: unchanged. `findNearestMatch` still works against the growing corpus. +- Formatter receives the new `LoopContext` shape and the new fields are present in the Bedrock request body (via the existing `aws-sdk-client-mock` ConverseCommand matcher). +- Existing RAG tests: unchanged. `findNearestMatch` still works against the growing per-source corpus. +- Existing handler tests: the `sourceOverrides` shape changes (each `SourceFetcher` is still replaced with a `() => Promise`, but the test now asserts ONE formatter call instead of N per-source calls). No tests for the RAG corpus-bloat rate (would require running the loop for hours to measure); called out as a known characteristic in the budget note, not a testable invariant. @@ -183,13 +239,13 @@ No tests for the RAG corpus-bloat rate (would require running the loop for hours Per the user instruction, no new `docs/0X-*.md` tutorial file. Two small changes: -- **`README.md`** — new "Loop mode" subsection under "Quick start" with the curl commands for `loop-start` / `loop-stop`, the env vars (`LOOP_TOKEN`, `LOOP_RULE_NAME`), and a one-line note that the loop is for local testing and should be stopped when done. -- **`docs/07-budget-protection.md`** — one paragraph added: with the loop running at 3-min cadence, expect ~960 Bedrock calls/day (2 sources × 480 ticks). At default model pricing, this is roughly $0.05–$0.10/day. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~28,800 rows/day; while the loop is short-lived this is fine. `loop-stop` disables the EventBridge rule, which is the way to actually shut down the recurring cost. +- **`README.md`** — new "Loop mode" subsection under "Quick start" with the `./scripts/loop-start.sh` / `./scripts/loop-stop.sh` commands, a one-liner explaining what the loop does, and a clear "stop the loop when you're done — `loop-stop` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops." +- **`docs/07-budget-protection.md`** — one paragraph added: with the loop running at 3-min cadence, expect ~480 Bedrock calls/day (1 combined post per tick × 480 ticks). At default model pricing, this is roughly $0.05–$0.10/day. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~960 rows/day while running; short-lived loops are fine, but `loop-stop` is the way to actually shut down the recurring cost. --- ## 9. Open concerns (out of scope for this spec) - **RAG corpus bloat over long runs.** If the loop is ever left running for days, `agent_notifications` and `agent_embeddings` grow linearly. A future spec could add a sliding-window cap or a vacuum job. This spec keeps the "every posted message is searchable forever" RAG invariant intact and relies on the short-lived nature of the loop in practice. -- **Loop token rotation.** `LOOP_TOKEN` is an env var set at deploy time; rotating it requires a redeploy. Same posture as `FETCH_TRIGGER_TOKEN`. -- **Unattended operation.** The loop is intended for local testing, not for being left on. The README's "Loop mode" subsection should explicitly say "stop the loop when you're done — `loop-stop` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops." +- **Loop token rotation.** `LOOP_TOKEN` is auto-generated at synth time and stays stable for the life of the stack. Rotating it requires a redeploy (which generates a new token and updates the stack output). Same posture as the existing `FETCH_TRIGGER_TOKEN`, but a redeploy is the only path — there's no manual override. +- **Per-source RAG lookups when only one source succeeds.** With one source down, the per-source RAG query for the other source still runs. The combined context reflects "weather only" (or "crypto only") and the LLM may reference the closest-past weather reading but not crypto. This is the right behavior — the LLM gets whatever data is available — but it does mean the `nearest_match_id` is set for the successful source and `null` for the failed one (which has no row this tick anyway). From 51fa2525b064270667b5d3c3e3c8517b5b840cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:31:13 -0400 Subject: [PATCH 06/26] spec: change loop cadence from 3 minutes to 5 minutes The 3-minute window is too tight against Bedrock + Titan + S3 + Discord latency. 5 minutes gives comfortable headroom and halves the Bedrock cost (288 ticks/day vs 480) and the RAG corpus growth (576 rows/day vs 960). All references throughout the spec updated. --- .../2026-08-09-loop-mode-poetry-design.md | 173 ++++++++++-------- 1 file changed, 92 insertions(+), 81 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index 5c4a241..fed39eb 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -2,71 +2,80 @@ **Date:** 2026-08-09 **Status:** Draft (pending user review) -**Scope:** Replaces the once-daily schedule with a 3-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + closest-match context, ending with a short haiku), and adds commands to start and stop the loop. `loop-stop` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. One Discord message per tick (combined weather + crypto + haiku), no dedup, no new user-facing tutorial doc. The loop token is auto-generated by the CDK stack at synth time so the user doesn't have to manage a secret. +**Scope:** Replaces the once-daily schedule with a 5-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + haiku, with a mechanical "Reminds me of: $similar" suffix that points at the closest past message), and adds commands to start and stop the loop. `loop-stop` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. One Discord message per tick (combined weather + crypto + haiku, plus optional "Reminds me of" suffix), no dedup, no new user-facing tutorial doc. The loop token is auto-generated by the CDK stack at synth time so the user doesn't have to manage a secret. --- ## 1. Purpose and constraints -The base tutorial posts one Discord message per day, on value change only. For local testing and quick iteration, a 3-minute cadence is more useful — you can see the LLM's output in seconds, not hours — and the message should be more varied (incorporating date, location, and a creative closing) rather than the current template-y "Weather update: 72F" / "Crypto update: 67234.10". When the test is over, the loop should actually stop running in AWS — no reason to keep a recurring trigger alive when no one is looking at the output. The control plane (start/stop) should be a single curl, with no secret to set up by hand. +The base tutorial posts one Discord message per day, on value change only. For local testing and quick iteration, a 5-minute cadence is more useful — you can see the LLM's output in minutes, not hours — and the message should be more varied (incorporating date, location, and a creative closing) rather than the current template-y "Weather update: 72F" / "Crypto update: 67234.10". When the test is over, the loop should actually stop running in AWS — no reason to keep a recurring trigger alive when no one is looking at the output. The control plane (start/stop) should be a single script invocation, with no secret to set up by hand. **Constraints carried over from the base spec:** standalone, TypeScript/Node 24/ESM, public-tutorial quality, no VPC/DB server, single-writer invariant, single-user, reader stays read-only. **New constraints this feature must respect:** -- **One writer, no dedup, one message per tick.** The existing `runFetch` is the only writer. It always fetches all sources, always formats ONCE with a combined context, always posts ONCE. The Discord channel gets one message per tick, not two. Per-source `agent_notifications` rows are still written (one per source per tick) so the status endpoint and RAG corpus work unchanged, but they all carry the same combined `formatted_message`. No per-source value-change dedup, no `FETCH_DEDUP` env var, no toggle. The loop is short-lived (minutes to hours) and meant for testing — a missed post is harmless, a silent post-skip is confusing. +- **One writer, no dedup, one message per tick.** The existing `runFetch` is the only writer. It always fetches all sources, always formats ONCE with a clean context (no RAG), then runs the RAG lookup on the LLM's output and appends a "Reminds me of" suffix mechanically. The Discord channel gets one message per tick. Per-source `agent_notifications` rows are still written (one per source per tick) so the status endpoint and RAG corpus work unchanged, but they all carry the same combined `formatted_message` and the same RAG match. +- **Two-step RAG: format first, then look up.** The LLM is never asked to reference a closest past reading. The RAG lookup happens after the format call: the writer embeds the LLM's output, queries the KNN corpus (no per-source filter), and appends `\n\nReminds me of: ` to the LLM's output before posting. This keeps the LLM prompt simple, the cosine similarity tight (query and corpus both key on the LLM's output text), and the suffix mechanical rather than LLM-driven. - **`loop-stop` actually disables the EventBridge rule.** A stopped loop means no further scheduled invocations, not a Lambda that returns early. Same Lambda, same Function URL, just no trigger firing. `loop-start` re-enables it. -- **Loop token is auto-generated, not user-set.** The CDK stack generates `LOOP_TOKEN` at synth time using a secure RNG, passes it to the Lambda env, and exposes it as a stack output. The `loop-start.sh` / `loop-stop.sh` scripts read the token from stack outputs at runtime — the user just runs the script, no `export LOOP_TOKEN=...` required. -- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the curl/script commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~480 Bedrock calls/day while running. No new `docs/0X-*.md`. +- **Loop token is auto-generated, not user-set.** The CDK stack generates `LOOP_TOKEN` at synth time using a secure RNG, passes it to the Lambda env, and exposes it as a stack output. The `loop-start.sh` / `loop-stop.sh` scripts read the token from stack outputs at runtime — the user just runs the script, no `export LOOP_TOKEN=...` required. The token is regenerated on each redeploy; this is acceptable for a single-user tutorial and the deliberate trade-off vs managing a secret manually. +- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the script commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~480 Bedrock calls/day while running. No new `docs/0X-*.md`. - **Numerology is out.** The closing beat is a short haiku the LLM generates, not a numerology sentence. LLMs produce cliché numerology platitudes; a haiku gives them actual creative room and reads as varied across runs. -- **No RAG corpus cap in this spec.** The RAG invariant "every posted message is searchable" stays intact. The corpus grows at 3-min cadence while the loop runs, which is fine because the loop is short-lived. A future spec could add a cap or vacuum job; that's out of scope here. +- **No RAG corpus cap in this spec.** The RAG invariant "every posted message is searchable" stays intact. The corpus grows at 5-min cadence while the loop runs, which is fine because the loop is short-lived. A future spec could add a cap or vacuum job; that's out of scope here. --- ## 2. Architecture The deploy changes (all in `infra/stack.ts`): -- The existing `FetchSchedule` EventBridge rule is given an explicit `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` (so the handler can refer to it stably), and its schedule is changed from `rate(1 day)` to `rate(3 minutes)`. Same Lambda target, same `{op:"fetch"}` payload. +- The existing `FetchSchedule` EventBridge rule is given an explicit `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` (so the handler can refer to it stably), and its schedule is changed from `rate(1 day)` to `rate(5 minutes)`. Same Lambda target, same `{op:"fetch"}` payload. - The Lambda role gains two new permissions: `events:EnableRule` and `events:DisableRule`, scoped to that rule's ARN. -- The Lambda environment gains `LOOP_RULE_NAME` (the rule's full name) and `LOOP_TOKEN` (a 24-byte hex value generated at synth time via `crypto.randomBytes(24).toString('hex')`). Both are also exposed as CloudFormation outputs. +- The Lambda environment gains `LOOP_RULE_NAME` (the rule's full name), `LOOP_TOKEN` (a 24-byte hex value generated at synth time via `crypto.randomBytes(24).toString('hex')`), and `LOOP_RULE_NAME`. Both are also exposed as CloudFormation outputs (`LoopToken`, `LoopRuleName`). +- The Lambda timeout is bumped from 30s to 60s. Haiku generation plus two Titan calls plus S3 GET/PUT plus the Discord post is comfortably under 30s in the typical case, but the extra headroom protects against transient Bedrock latency without overlapping 5-minute ticks. The runtime changes: -- `runFetch` is restructured to the combined-message flow. It fetches all sources up front, runs the per-source RAG lookup for each (keyed on the per-source value), then calls the formatter ONCE with a combined `LoopContext`, posts ONCE, and writes per-source `agent_notifications` rows (each with the same combined `formatted_message`) plus a per-source `agent_embeddings` row (each embedding the same combined message text). The combined Discord post is the only user-visible output of a tick. -- The formatter's user prompt gains structured fields: `date`, `location`, `weatherValue`, `cryptoValue`, plus per-source closest-match pairs. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, (2) naturally reference one or both closest-past readings if provided, and (3) end with a brief haiku (5-7-5) that incorporates the temperature, the crypto value, and the day's vibe. +- `runFetch` is restructured to the combined-message, two-step RAG flow. It fetches all sources up front, formats ONCE with a clean context (no RAG), then runs the RAG lookup on the LLM's output, appends the "Reminds me of" suffix if a match exists, posts ONCE, and writes per-source `agent_notifications` rows (each with the same combined `formatted_message` and the same RAG match). The RAG corpus entry uses the LLM's pre-suffix output as the embed text, so the query and the corpus both key on the same text. +- The formatter's user prompt gains structured fields: `date`, `location`, `weatherValue`, `cryptoValue`. The LLM is **not** told about RAG — the closest-past reference is appended mechanically after the LLM call. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, varying phrasing across runs, and (2) end with a brief haiku (5-7-5) that incorporates the temperature, the crypto value, and the day's vibe. The control plane changes: - Two new ops, `loop-start` and `loop-stop`, gated by `LOOP_TOKEN` (same constant-time check pattern as `FETCH_TRIGGER_TOKEN`). They call `EventBridge.EnableRuleCommand` / `DisableRuleCommand` on the rule named by `LOOP_RULE_NAME`. They do not touch the SQLite snapshot — EventBridge is the source of truth. They return `{ statusCode: 200, body: JSON.stringify({ loopState: 'ENABLED' | 'DISABLED' }) }` based on the call's result. - The `loop-start.sh` / `loop-stop.sh` scripts read `FUNCTION_URL` and `LOOP_TOKEN` from stack outputs (matching the existing `smoke.sh` pattern), curl the right op, and print success/failure. ``` -EventBridge rate(3 min) ──> Lambda (op:"fetch") +EventBridge rate(5 min) ──> Lambda (op:"fetch") │ (rule is ENABLED by default; │ loop-stop flips it to DISABLED, │ which stops all further invocations) ▼ runFetch │ - ├─ fetch all sources up front (per-source try/catch - │ isolates failures; failed sources are absent - │ from the context but the others still post) + ├─ STEP 1: fetch all sources up front + │ (per-source try/catch isolates failures; + │ failed sources are absent from the context) │ - ├─ per-source RAG lookup: embed each value, find - │ nearest past same-source notification + ├─ STEP 2: formatter.format(LoopContext) ← ONE LLM call + │ LoopContext = {date, location, weatherValue, cryptoValue} + │ → preMessage (LLM's output: friendly comment + haiku) │ - ├─ formatter.format(LoopContext) ← ONE call + ├─ STEP 3: embed preMessage, KNN over agent_embeddings + │ (no per-source filter) → nearest past notification + │ → if match exists: preMessage += "\n\nReminds me of: " + match.formattedMessage + │ → finalMessage = preMessage [with optional suffix] │ - ├─ poster.post(formatted) ← ONE post + ├─ STEP 4: poster.post(finalMessage) ← ONE post │ - ├─ per-source: insert agent_notifications row - │ (same formatted_message, per-source value, - │ per-source nearest_match), embed combined - │ message and store under that source label + ├─ STEP 5: per-source insert agent_notifications + │ (formatted_message = finalMessage, + │ same nearest_match_id on all rows) + │ + ├─ STEP 6: embed preMessage (NOT finalMessage) and + │ insertEmbedding per source — query and corpus + │ both key on the LLM's pre-suffix output │ └─ publish snapshot to S3 (conditional) scripts/loop-stop.sh: - FUNCTION_URL=$(aws cloudformation describe-stacks --query "..." AgentFunctionUrl) - LOOP_TOKEN=$(aws cloudformation describe-stacks --query "..." LoopToken) + FUNCTION_URL=$(aws cloudformation describe-stacks ... AgentFunctionUrl) + LOOP_TOKEN=$(aws cloudformation describe-stacks ... LoopToken) curl -X POST "$FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' └─ EventBridge.DisableRuleCommand(Name: LOOP_RULE_NAME) → rule state = DISABLED, no more ticks @@ -78,9 +87,9 @@ scripts/loop-stop.sh: **No schema changes.** `agent_sources`, `agent_notifications`, `agent_runs`, and `agent_embeddings` are unchanged. -`agent_notifications` gains one row per source per tick (so ~960 rows/day at 3-min cadence). All rows from the same tick carry the same `formatted_message` (the combined Discord post) and the same `posted_at`. The `value` column is the per-source value, and `nearest_match_id` / `nearest_match_distance` are the per-source RAG match. This preserves the existing schema and query patterns (status endpoint, RAG KNN) without modification. +`agent_notifications` gains one row per source per tick (so ~576 rows/day at 5-min cadence). All rows from the same tick carry the same `formatted_message` (the final posted message, with "Reminds me of" suffix if a match exists), the same `posted_at`, and the same `nearest_match_id` / `nearest_match_distance` (the single global RAG match for the tick — null if no history yet or the lookup failed). The `value` column is the per-source value. This preserves the existing schema and query patterns (status endpoint, RAG KNN) without modification. -`agent_embeddings` gains one row per source per tick (the embedding of the combined message, indexed under each source label). Two rows for the same text is a small redundancy but lets the per-source KNN query keep working unchanged. +`agent_embeddings` gains one row per source per tick. The embedded text is the LLM's **pre-suffix** output (the friendly comment + haiku, without the "Reminds me of" line). Two rows for the same text is a small redundancy but lets the global KNN query (no per-source filter) keep working unchanged — the LLM output is what the corpus keys on, not the posted-with-suffix text. Both grow at the same rate as before — the loop is short-lived so the absolute size stays small. A RAG corpus cap or vacuum job is a future spec, called out as out of scope. @@ -98,29 +107,11 @@ In practice, the CDK stack always sets both. End users see them only as stack ou ### 4.2 `src/format/types.ts` — new `LoopContext` ```typescript -export interface SimilarPastResult { - formattedMessage: string; - postedAt: number; - /** The notification id and distance of the closest same-source past notification - * (RAG design spec §7). Carried alongside the formatted message so the writer - * can populate the per-source `nearest_match_id` / `nearest_match_distance` - * columns on `agent_notifications`. */ - notificationId: number; - distance: number; -} - export interface LoopContext { date: string; // ISO date, e.g. "2026-08-09" location: string; // e.g. "NYC" weatherValue: string; // current weather for the location, e.g. "72F" cryptoValue: string; // current BTC USD price as a string, e.g. "67234.10" - /** Per-source closest past reading (the past combined message that was posted - * when a similar value was observed). Either may be null if the source has - * no history yet or the RAG lookup failed and was isolated. */ - similarPast: { - weather: SimilarPastResult | null; - crypto: SimilarPastResult | null; - }; } export interface MessageFormatter { @@ -130,7 +121,7 @@ export interface MessageFormatter { The signature change is internal — `MessageFormatter` is a TypeScript type used only by `runFetch` and `localFetch` (and the format module's own tests); it is not a public package surface. `LocalTemplateFormatter` and `BedrockFormatter` are updated to match. -`SimilarPastResult` is the per-source closest-match record. Previously it was a thin `{ formattedMessage, postedAt }`; it now also carries `notificationId` and `distance` so the writer can populate the corresponding columns on `agent_notifications` without a second RAG lookup. The existing per-source `format()` callers in tests that build a `SimilarPastResult` get a `notificationId: 0, distance: 0` stub — those tests don't care about RAG, only the formatter's behavior. +**RAG is no longer in the formatter's context.** The LLM is not asked to reference a closest past reading. The RAG lookup happens in the writer after the format call; the suffix is appended mechanically. This keeps the LLM prompt clean, the cosine match tight, and the implementation obvious. ### 4.3 `src/format/bedrock.ts` — new system prompt @@ -139,50 +130,60 @@ SYSTEM: You write a short, friendly Discord message for a daily-checkin bot that posts a combined weather and crypto snapshot every few minutes. The user message below contains today's date, the location, the current weather, -the current crypto value, and (when available) the closest past reading -for each. Write a brief comment (one or two sentences) that draws on -these inputs — vary your phrasing across runs; do not repeat the same -template. If one or both closest-past readings are included, you may -naturally reference them, but you are not required to. End with a short -haiku (three lines, 5-7-5 syllables) that weaves in the temperature, the -crypto value, and the day's vibe. Reply with the message text only — no -quotes, no preamble, no markdown. +and the current crypto value. Write a brief comment (one or two sentences) +that draws on these inputs — vary your phrasing across runs; do not repeat +the same template. End with a short haiku (three lines, 5-7-5 syllables) +that weaves in the temperature, the crypto value, and the day's vibe. +Reply with the message text only — no quotes, no preamble, no markdown. ``` +No "closest past reading" mention — the LLM doesn't see RAG context, so it doesn't try to reference one. The "Reminds me of" suffix is appended after the LLM call. + The local template formatter is updated to a minimal `"{date} — {weatherValue} / {cryptoValue}"` shape so its tests still pin the new context fields. It is not expected to generate a haiku — it's a test-only stub. -### 4.4 `src/agent/fetch.ts` — combined-message writer +### 4.4 `src/agent/fetch.ts` — combined-message, two-step RAG writer -The writer is restructured from "per-source format+post loop" to "fetch all, format once, post once, write per-source rows." +The writer is restructured from "per-source format+post loop" to "fetch all, format once, RAG-lookup-on-output, post once, write per-source rows." New flow: 1. Hydrate, open, bootstrap, start the `agent_runs` row (unchanged). 2. For each source, call `source.fetch()` and collect results into a per-source map. Per-source failures are caught and folded into `errors`; the source is omitted from the map. -3. For each successful source, call `params.embedder.embed(rawValue)` and `findNearestMatch(db, sourceName, vector)` to populate the per-source `similarPast` map. Per-source embed/lookup failures are caught and folded into `errors`; the corresponding `similarPast` entry is `null`. -4. If the per-source map is non-empty, build a `LoopContext` with `date = ISO slice of now()`, `location = config.weatherLocation`, `weatherValue` / `cryptoValue` from the map (or `''` if a source failed), and `similarPast` from the RAG map. Call `params.formatter.format(ctx)` and `params.poster.post(formatted)`. If format or post throws, fold the error into `errors`; no per-source rows are written for this tick. -5. For each source in the map, insert one `agent_notifications` row with the per-source `value`, the combined `formatted_message`, the per-source `nearest_match_id` / `nearest_match_distance`, and the shared `posted_at`. Update `agent_sources` with the per-source last-value/last-fetched/last-posted timestamps. Embed the combined `formatted_message` and `insertEmbedding(db, notificationId, vector)`. Per-source embed/store failures are caught and folded into `errors`; the row stays. -6. Finish the `agent_runs` row with `notificationsSent: 1` (one combined post per tick, regardless of how many sources contributed) or `0` if step 4 was skipped. -7. Publish the snapshot (unchanged). +3. **If the per-source map is empty** (both sources failed), skip the rest of the tick. Finish the `agent_runs` row with `notificationsSent: 0` and publish the snapshot. +4. Build a `LoopContext` with `date = ISO slice of now()`, `location = config.weatherLocation`, `weatherValue` / `cryptoValue` from the map (or `''` if a source failed). Call `params.formatter.format(ctx)` → `preMessage`. If the format call throws, fold the error into `errors`, skip the rest of the tick. +5. **Two-step RAG.** Call `params.embedder.embed(preMessage)` to get `preVector`. Call `findNearestMatch(db, preVector)` (no per-source filter — global KNN over `agent_embeddings`) to get `{ notificationId, distance, formattedMessage: pastMessage }` or `null`. If the embed or lookup throws, fold the error into `errors` and proceed with no suffix. +6. Build `finalMessage = match !== null ? preMessage + "\n\nReminds me of: " + pastMessage : preMessage`. +7. Call `params.poster.post(finalMessage)`. If the post throws, fold the error into `errors`, skip the per-source inserts. +8. For each source in the per-source map, insert one `agent_notifications` row with the per-source `value`, `formatted_message = finalMessage`, `nearest_match_id = match?.notificationId ?? null`, `nearest_match_distance = match?.distance ?? null`, and the shared `posted_at`. Update `agent_sources` with the per-source last-value/last-fetched/last-posted timestamps. +9. **Embed the LLM's pre-suffix output and store it** under each source label: `insertEmbedding(db, notificationId, preVector)`. The corpus keys on the LLM's output, not the posted-with-suffix message — this is what makes the cosine match tight. Per-source embed/store failures are caught and folded into `errors`; the row stays. +10. Finish the `agent_runs` row with `notificationsSent: 1` (one combined post per tick, regardless of how many sources contributed). +11. Publish the snapshot (unchanged). + +The `runFetch` signature gains `weatherLocation: string` (so the writer can populate the context's `location` field). The result shape `RunFetchResult` is unchanged; `notificationsSent` is now `0 | 1` per tick. + +### 4.5 `src/rag/similarity.ts` — global KNN + +`findNearestMatch(db, queryVector)` — the function loses its `source` parameter. The SQL removes the `source = ?` filter; the match is the single most similar notification across all sources. The return shape is unchanged: `{ notificationId, distance, formattedMessage, postedAt } | null`. -The `runFetch` signature gains `weatherLocation: string` (so the writer can populate the context's `location` field) and a `now: () => number` is already injected; `date` is derived from `now()` in step 4. The result shape `RunFetchResult` is unchanged; `notificationsSent` is now `0 | 1` per tick. +The function is now called once per tick (in step 5 above), not once per source. The KNN is global; the per-source labels in `agent_embeddings` are no longer used for filtering (they remain as the `notification_id → source` join path for the status endpoint, unchanged). -### 4.5 `src/handler.ts` — new ops, EventBridge client +### 4.6 `src/handler.ts` — new ops, EventBridge client - Adds an `EventBridgeClient` to the `InjectedClients` interface (with a default constructed from `config.region`), constructed once per invocation like the existing `S3Client` and `BedrockRuntimeClient`. - `op === 'loop-start'`: token check → `EnableRuleCommand({ Name: config.loopRuleName })` → return `{ loopState: 'ENABLED' }`. On error, return `{ statusCode: 500, body: JSON.stringify({ error: ... }) }`. - `op === 'loop-stop'`: token check → `DisableRuleCommand({ Name: config.loopRuleName })` → return `{ loopState: 'DISABLED' }`. Same error shape. - The `status` op is unchanged (no new fields). -### 4.6 `infra/stack.ts` — schedule, env, IAM, output +### 4.7 `infra/stack.ts` — schedule, env, IAM, output, timeout - Set `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` on the `FetchSchedule` rule. - Change `schedule: events.Schedule.rate(cdk.Duration.days(1))` to `events.Schedule.rate(cdk.Duration.minutes(3))`. -- Add `LOOP_RULE_NAME: rule.ruleName` and `LOOP_TOKEN: randomBytes(24).toString('hex')` to the Lambda environment. The token is generated at synth time and stays stable for the life of the stack. +- Add `LOOP_RULE_NAME: rule.ruleName` and `LOOP_TOKEN: randomBytes(24).toString('hex')` to the Lambda environment. The token is generated at synth time and stays stable for the life of the stack (a redeploy regenerates it). - Add a new IAM `PolicyStatement`: `events:EnableRule`, `events:DisableRule` on `rule.ruleArn`. The `retryAttempts: 0` and `RuleTargetInput.fromObject({ op: 'fetch' })` already in place stay. - Add two new `CfnOutput`: `LoopToken` (the token) and `LoopRuleName` (the rule's name). The token output is what the start/stop scripts read. +- Bump `timeout: cdk.Duration.seconds(30)` to `cdk.Duration.seconds(60)` to give the two-step RAG flow (1 Converse + 2 Titan + S3 + Discord) comfortable headroom against transient Bedrock latency. (A 5-minute cadence leaves plenty of slack between ticks even with a 60s timeout; the 60s is a one-line safety bump.) -### 4.7 `scripts/loop-start.sh` / `scripts/loop-stop.sh` +### 4.8 `scripts/loop-start.sh` / `scripts/loop-stop.sh` Two new scripts matching the style of `scripts/smoke.sh`. Both read `FUNCTION_URL` and `LOOP_TOKEN` from the stack's CloudFormation outputs (same `aws cloudformation describe-stacks` + `jq` pattern as `smoke.sh` reads `AgentFunctionName` and `AgentFunctionUrl`), then curl the right op. The user just runs `./scripts/loop-stop.sh` — no env vars to set, no token to copy, no auth to configure beyond the AWS CLI credentials the smoke script already needs. @@ -194,14 +195,16 @@ The scripts also print the rule's current state on success so the user can see t | Scenario | Before | After | |---|---|---| -| Daily fetch at 1 day | One fetch per day, dedup on | Schedule changed to 3 min, no dedup. Same `op:"fetch"`, same Lambda. | -| Loop at 3 min | n/a | One Discord message per tick (combined weather + crypto + haiku). Per-source `agent_notifications` rows still written. | -| Message content | "Weather update: 72F" / "Crypto update: 67234.10" (one per source) | One message per tick: date, location, weather, crypto, optional closest-past references, ends with a haiku | +| Daily fetch at 1 day | One fetch per day, dedup on | Schedule changed to 5 min, no dedup. Same `op:"fetch"`, same Lambda. | +| Loop at 5 min | n/a | One Discord message per tick: date, location, weather, crypto, haiku, optional `\n\nReminds me of: ` suffix. | +| Message content | "Weather update: 72F" / "Crypto update: 67234.10" (one per source) | One message per tick: LLM-generated friendly comment + haiku + mechanical "Reminds me of" suffix from the global RAG match. | | Dedup on value change | Always on | Removed. Writer always posts. | +| RAG query | Per-source, on raw value, against formatted-message corpus | Global, on LLM's pre-suffix output, against pre-suffix-output corpus. No per-source filter. | | Stop the loop | n/a | `./scripts/loop-stop.sh`. EventBridge rule state = DISABLED. No further scheduled invocations. | | Start the loop | n/a | `./scripts/loop-start.sh`. Rule state = ENABLED. Subsequent ticks post as normal. | -| Status endpoint | snapshotVersion, sources, recentNotifications | Unchanged | +| Status endpoint | snapshotVersion, sources, recentNotifications | Unchanged (still shows the full posted message per `agent_notifications` row, including the "Reminds me of" suffix). | | Loop token | n/a | Auto-generated by CDK at synth, exposed as `LoopToken` stack output. Scripts read it; user never copies a secret. | +| Lambda timeout | 30s | 60s (haiku generation + 2 Titan calls + S3 + Discord, with headroom). | --- @@ -209,9 +212,12 @@ The scripts also print the rule's current state on success so the user can see t - A `loop-start` / `loop-stop` op that fails the EventBridge API call returns 500 with the error message. The DB snapshot is not touched (EventBridge is the source of truth, not the snapshot). - A `runFetch` invocation that fails because the EventBridge call disabled the rule is not a concern — `runFetch` is invoked *by* EventBridge, not the other way around. The `events:DisableRule` call simply means EventBridge stops calling `runFetch`. -- A per-source fetch failure is caught in step 2 and folded into `errors`. The other sources still contribute to the combined message. `agent_sources` is not updated for the failed source (no `last_value` written). -- A formatter or post failure in step 4 is caught and folded into `errors`. No per-source `agent_notifications` rows are written for this tick; the run still completes. -- A per-source embed/insert failure in step 5 is caught and folded into `errors`. The notification row stays; only the RAG corpus fails to grow by this entry. +- A per-source fetch failure in step 2 is caught and folded into `errors`. The other sources still contribute to the combined message. `agent_sources` is not updated for the failed source. +- If all sources fail (step 3), the tick is a no-op for the rest of the flow. `notificationsSent: 0`. The snapshot is still published (it carries the `agent_runs` row recording the failure). +- A formatter error in step 4 is caught and folded into `errors`. The RAG lookup, post, and per-source inserts are skipped for this tick. `notificationsSent: 0`. +- A RAG embed or lookup error in step 5 is caught and folded into `errors`. The flow proceeds with no suffix. The LLM's output is still posted; no "Reminds me of" line is appended. The closest-match columns on `agent_notifications` are null for this tick. +- A post error in step 7 is caught and folded into `errors`. The per-source inserts are skipped for this tick. `notificationsSent: 0`. +- A per-source embed/store failure in step 9 is caught and folded into `errors`. The notification row stays; only the RAG corpus fails to grow by this entry. - A Discord 4xx is not retried (existing behavior); a 5xx gets one ~250ms retry (existing). Same rules apply. --- @@ -220,15 +226,18 @@ The scripts also print the rule's current state on success so the user can see t Unit tests (vitest) for: -- `runFetch` happy path: both sources succeed, formatter is called ONCE with a combined `LoopContext`, Discord is posted ONCE, two `agent_notifications` rows are written (one per source) with the same `formatted_message`, two `agent_embeddings` rows are inserted, snapshot is published. -- `runFetch` with one source failing (e.g., coingecko down): the other source still contributes; formatter receives `weatherValue: '72F'`, `cryptoValue: ''`; one notification row; `errors` includes the failed source. +- `runFetch` happy path with RAG history: both sources succeed, formatter is called ONCE with a clean `LoopContext` (no RAG fields), the LLM's output is embedded, KNN returns a match, `finalMessage` includes the `\n\nReminds me of: ` suffix, Discord is posted ONCE, two `agent_notifications` rows are written (one per source) with the same `formatted_message` (including the suffix) and the same `nearest_match_id`, two `agent_embeddings` rows are inserted with the **pre-suffix** text. +- `runFetch` first-tick path (no RAG history): the LLM is called, the RAG lookup returns null (no past notifications), `finalMessage = preMessage` (no suffix), one combined Discord post, two notification rows with `nearest_match_id = null`. +- `runFetch` with one source failing (e.g., coingecko down): the other source still contributes; formatter receives `weatherValue: '72F'`, `cryptoValue: ''`; one notification row per successful source; `errors` includes the failed source. - `runFetch` with both sources failing: formatter is not called, no Discord post, no notification rows, `errors` includes both sources, `notificationsSent: 0`. -- `runFetch` formatter failure: caught and folded into `errors`, no notification rows, no Discord post, snapshot is still published. +- `runFetch` formatter failure: caught and folded into `errors`, no RAG lookup, no Discord post, no notification rows, snapshot is still published. +- `runFetch` RAG lookup failure (Titan embed throws): caught and folded into `errors`, `finalMessage = preMessage` (no suffix), post still happens, `nearest_match_id = null` on the rows. +- `runFetch` post failure: caught and folded into `errors`, no per-source notification rows, no embeddings, snapshot is still published. - `loop-start` op with valid `LOOP_TOKEN`: calls `EventBridge.EnableRuleCommand` with `LOOP_RULE_NAME`, returns 200 `{loopState: 'ENABLED'}`. - `loop-stop` op with valid `LOOP_TOKEN`: calls `EventBridge.DisableRuleCommand`, returns 200 `{loopState: 'DISABLED'}`. - `loop-start` / `loop-stop` without `LOOP_TOKEN`: 403, no EventBridge call. - Formatter receives the new `LoopContext` shape and the new fields are present in the Bedrock request body (via the existing `aws-sdk-client-mock` ConverseCommand matcher). -- Existing RAG tests: unchanged. `findNearestMatch` still works against the growing per-source corpus. +- `findNearestMatch` with no per-source filter: returns the closest notification across all sources, not the closest within a single source. Existing RAG tests that exercised per-source filtering are updated. - Existing handler tests: the `sourceOverrides` shape changes (each `SourceFetcher` is still replaced with a `() => Promise`, but the test now asserts ONE formatter call instead of N per-source calls). No tests for the RAG corpus-bloat rate (would require running the loop for hours to measure); called out as a known characteristic in the budget note, not a testable invariant. @@ -239,13 +248,15 @@ No tests for the RAG corpus-bloat rate (would require running the loop for hours Per the user instruction, no new `docs/0X-*.md` tutorial file. Two small changes: -- **`README.md`** — new "Loop mode" subsection under "Quick start" with the `./scripts/loop-start.sh` / `./scripts/loop-stop.sh` commands, a one-liner explaining what the loop does, and a clear "stop the loop when you're done — `loop-stop` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops." -- **`docs/07-budget-protection.md`** — one paragraph added: with the loop running at 3-min cadence, expect ~480 Bedrock calls/day (1 combined post per tick × 480 ticks). At default model pricing, this is roughly $0.05–$0.10/day. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~960 rows/day while running; short-lived loops are fine, but `loop-stop` is the way to actually shut down the recurring cost. +- **`README.md`** — new "Loop mode" subsection under "Quick start" with the `./scripts/loop-start.sh` / `./scripts/loop-stop.sh` commands, a one-liner explaining the loop and the "Reminds me of" feature, and a clear "stop the loop when you're done — `loop-stop` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops." +- **`docs/07-budget-protection.md`** — one paragraph added: with the loop running at 5-min cadence, expect ~864 Bedrock calls/day (1 Converse + 2 Titan per tick × 288 ticks). At default model pricing, this is roughly $0.03–$0.06/day. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~576 rows/day while running; short-lived loops are fine, but `loop-stop` is the way to actually shut down the recurring cost. --- ## 9. Open concerns (out of scope for this spec) -- **RAG corpus bloat over long runs.** If the loop is ever left running for days, `agent_notifications` and `agent_embeddings` grow linearly. A future spec could add a sliding-window cap or a vacuum job. This spec keeps the "every posted message is searchable forever" RAG invariant intact and relies on the short-lived nature of the loop in practice. -- **Loop token rotation.** `LOOP_TOKEN` is auto-generated at synth time and stays stable for the life of the stack. Rotating it requires a redeploy (which generates a new token and updates the stack output). Same posture as the existing `FETCH_TRIGGER_TOKEN`, but a redeploy is the only path — there's no manual override. -- **Per-source RAG lookups when only one source succeeds.** With one source down, the per-source RAG query for the other source still runs. The combined context reflects "weather only" (or "crypto only") and the LLM may reference the closest-past weather reading but not crypto. This is the right behavior — the LLM gets whatever data is available — but it does mean the `nearest_match_id` is set for the successful source and `null` for the failed one (which has no row this tick anyway). +- **RAG corpus bloat over long runs.** If the loop is ever left running for days, `agent_notifications` and `agent_embeddings` grow linearly (~576 rows/day each at 5-min cadence with two sources). A future spec could add a sliding-window cap or a vacuum job. This spec keeps the "every posted message is searchable forever" RAG invariant intact and relies on the short-lived nature of the loop in practice. +- **Loop token rotation.** `LOOP_TOKEN` is auto-generated at synth time and stays stable for the life of the stack. A redeploy regenerates the token and updates the stack output. There's no manual override path — for a single-user tutorial this is the deliberate trade-off for not managing a secret by hand. If the token is leaked, the recovery is `npm run deploy` (which writes a new token). +- **Per-source RAG metadata on the row.** With the new design, the `nearest_match_id` / `nearest_match_distance` columns are the same on all per-source rows of a tick (the single global RAG match for the combined message). The status endpoint will show the same match twice (once per source row). This is mildly redundant but not wrong; if a future spec wants to deduplicate the per-source rows into one row per tick, that's a schema change worth its own design. +- **"Reminds me of" message length.** The past message (the matched `formatted_message`, which itself may include a "Reminds me of" suffix) is appended verbatim. Discord's 2000-char limit caps the chain length. At 5-min cadence, a single past message is short enough that this is fine for any reasonable run length, but a future spec could truncate the suffix or strip nested "Reminds me of" lines. +- **CDK synth-time token across multi-env deploys.** `crypto.randomBytes(24)` runs at synth time, so the same `cdk.out` template deployed to multiple environments carries the same token. For a single-user tutorial this is acceptable; the documented behavior is "redeploy to rotate." From e0fb955e87513bd89746be7d057ab41b82dfa289 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:38:43 -0400 Subject: [PATCH 07/26] fix(spec): add base_message column to prevent suffix snowball The previous design stored the full posted message (with 'Reminds me of' suffix) as the RAG match's text, and the corpus embed was on that same posted text. Each tick's suffix therefore included the previous tick's full posted text recursively, growing past Discord's 2000-char limit in ~13 ticks. A Discord 400 then skipped the inserts, the loop jammed permanently, and the same oversized past row was retrieved every 5 min. Fix: add a new 'base_message' column on agent_notifications holding the LLM's pre-suffix output. The formatted_message column keeps the full posted message (with optional suffix). The RAG embed is on base_message, and findNearestMatch returns the past tick's base_message for the suffix, never its formatted_message. The posted message is bounded at one base + one suffix (~300 chars), no chain growth, no jamming. - New ALTER TABLE migration via the existing PRAGMA table_info guard in bootstrap() (mirrors the RAG spec's column-addition pattern). - findNearestMatch return shape changes from {formattedMessage} to {baseMessage}; called out in section 4.5. - New 'snowball regression test' in section 7: 20 simulated ticks with every finalMessage.length < 500. - Section 9 'Reminds me of message length' concern resolved (was the open concern; now the bounded-length property is by construction). --- .../2026-08-09-loop-mode-poetry-design.md | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index fed39eb..c5dad8c 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -16,6 +16,7 @@ The base tutorial posts one Discord message per day, on value change only. For l - **One writer, no dedup, one message per tick.** The existing `runFetch` is the only writer. It always fetches all sources, always formats ONCE with a clean context (no RAG), then runs the RAG lookup on the LLM's output and appends a "Reminds me of" suffix mechanically. The Discord channel gets one message per tick. Per-source `agent_notifications` rows are still written (one per source per tick) so the status endpoint and RAG corpus work unchanged, but they all carry the same combined `formatted_message` and the same RAG match. - **Two-step RAG: format first, then look up.** The LLM is never asked to reference a closest past reading. The RAG lookup happens after the format call: the writer embeds the LLM's output, queries the KNN corpus (no per-source filter), and appends `\n\nReminds me of: ` to the LLM's output before posting. This keeps the LLM prompt simple, the cosine similarity tight (query and corpus both key on the LLM's output text), and the suffix mechanical rather than LLM-driven. +- **No suffix snowball.** The LLM's pre-suffix output is stored in a new `base_message` column on `agent_notifications` and is the only thing that gets embedded into the RAG corpus. The `formatted_message` column continues to hold the full posted message (with "Reminds me of" suffix if present). The RAG match returns the past tick's `base_message` for the suffix, never its `formatted_message`. Because `base_message` is always the LLM's clean pre-suffix output, its size is bounded (~150 chars), the "Reminds me of" suffix stays bounded, and the posted message never grows past one base message + one suffix. Without this separation, appending "Reminds me of: $past" each tick recursively accumulates past messages and the posted text grows past Discord's 2000-char limit after ~13 ticks, jamming the loop. - **`loop-stop` actually disables the EventBridge rule.** A stopped loop means no further scheduled invocations, not a Lambda that returns early. Same Lambda, same Function URL, just no trigger firing. `loop-start` re-enables it. - **Loop token is auto-generated, not user-set.** The CDK stack generates `LOOP_TOKEN` at synth time using a secure RNG, passes it to the Lambda env, and exposes it as a stack output. The `loop-start.sh` / `loop-stop.sh` scripts read the token from stack outputs at runtime — the user just runs the script, no `export LOOP_TOKEN=...` required. The token is regenerated on each redeploy; this is acceptable for a single-user tutorial and the deliberate trade-off vs managing a secret manually. - **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the script commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~480 Bedrock calls/day while running. No new `docs/0X-*.md`. @@ -58,7 +59,7 @@ EventBridge rate(5 min) ──> Lambda (op:"fetch") │ ├─ STEP 3: embed preMessage, KNN over agent_embeddings │ (no per-source filter) → nearest past notification - │ → if match exists: preMessage += "\n\nReminds me of: " + match.formattedMessage + │ → if match exists: preMessage += "\n\nReminds me of: " + match.baseMessage │ → finalMessage = preMessage [with optional suffix] │ ├─ STEP 4: poster.post(finalMessage) ← ONE post @@ -85,13 +86,26 @@ scripts/loop-stop.sh: ## 3. Data model -**No schema changes.** `agent_sources`, `agent_notifications`, `agent_runs`, and `agent_embeddings` are unchanged. +**One new column on `agent_notifications`.** The RAG spec already added `nearest_match_id` and `nearest_match_distance`; this spec adds `base_message TEXT` (nullable). Existing columns are unchanged. -`agent_notifications` gains one row per source per tick (so ~576 rows/day at 5-min cadence). All rows from the same tick carry the same `formatted_message` (the final posted message, with "Reminds me of" suffix if a match exists), the same `posted_at`, and the same `nearest_match_id` / `nearest_match_distance` (the single global RAG match for the tick — null if no history yet or the lookup failed). The `value` column is the per-source value. This preserves the existing schema and query patterns (status endpoint, RAG KNN) without modification. +```sql +ALTER TABLE agent_notifications ADD COLUMN base_message TEXT; +``` + +The migration is added to `bootstrap()` via the same `PRAGMA table_info` guard the RAG spec uses for its own column additions — SQLite has no `ADD COLUMN IF NOT EXISTS`. A snapshot from before this feature shipped will have `base_message = NULL` on all rows; the status endpoint surfaces null as "—" (pre-loop row); the RAG query ignores them (the corpus embeds only rows with a non-null `base_message`, and any row without one is simply not a match candidate). + +The two columns separate two distinct things: + +- **`formatted_message`** — the full posted message (with "Reminds me of" suffix if a match exists). This is what the Discord webhook sends and what the status endpoint shows. +- **`base_message`** — the LLM's pre-suffix output (the friendly comment + haiku, without any "Reminds me of" line). This is what gets embedded into the RAG corpus and is what `findNearestMatch` returns for the suffix. + +The two diverge only when a past match exists. On the first tick and on RAG failures, `formatted_message = base_message`. The RAG query and the corpus both key on `base_message` (bounded ~150 chars). The "Reminds me of" suffix is built from the past match's `base_message`, never its `formatted_message`, so the snowball effect — where each tick's "Reminds me of" includes the previous tick's suffix, recursively — is impossible. + +`agent_notifications` gains one row per source per tick (~576 rows/day at 5-min cadence). All rows from the same tick carry the same `formatted_message` (with "Reminds me of" suffix if a match exists), the same `base_message` (the LLM's pre-suffix output), the same `posted_at`, and the same `nearest_match_id` / `nearest_match_distance` (the single global RAG match — null if no history yet or the lookup failed). The `value` column is the per-source value. -`agent_embeddings` gains one row per source per tick. The embedded text is the LLM's **pre-suffix** output (the friendly comment + haiku, without the "Reminds me of" line). Two rows for the same text is a small redundancy but lets the global KNN query (no per-source filter) keep working unchanged — the LLM output is what the corpus keys on, not the posted-with-suffix text. +`agent_embeddings` gains one row per source per tick. The embedded text is `base_message` (the LLM's pre-suffix output). The two rows for the same tick (one per source) are intentional — they keep the global KNN query unchanged while letting the per-source `agent_notifications` rows remain queryable. A future spec could collapse these to one row per tick; out of scope here. -Both grow at the same rate as before — the loop is short-lived so the absolute size stays small. A RAG corpus cap or vacuum job is a future spec, called out as out of scope. +Both grow at the same rate as before. A RAG corpus cap or vacuum job is a future spec, called out as out of scope. --- @@ -151,19 +165,19 @@ New flow: 2. For each source, call `source.fetch()` and collect results into a per-source map. Per-source failures are caught and folded into `errors`; the source is omitted from the map. 3. **If the per-source map is empty** (both sources failed), skip the rest of the tick. Finish the `agent_runs` row with `notificationsSent: 0` and publish the snapshot. 4. Build a `LoopContext` with `date = ISO slice of now()`, `location = config.weatherLocation`, `weatherValue` / `cryptoValue` from the map (or `''` if a source failed). Call `params.formatter.format(ctx)` → `preMessage`. If the format call throws, fold the error into `errors`, skip the rest of the tick. -5. **Two-step RAG.** Call `params.embedder.embed(preMessage)` to get `preVector`. Call `findNearestMatch(db, preVector)` (no per-source filter — global KNN over `agent_embeddings`) to get `{ notificationId, distance, formattedMessage: pastMessage }` or `null`. If the embed or lookup throws, fold the error into `errors` and proceed with no suffix. -6. Build `finalMessage = match !== null ? preMessage + "\n\nReminds me of: " + pastMessage : preMessage`. +5. **Two-step RAG.** Call `params.embedder.embed(preMessage)` to get `preVector`. Call `findNearestMatch(db, preVector)` (no per-source filter — global KNN over `agent_embeddings`) to get `{ notificationId, distance, baseMessage: pastBaseMessage }` or `null`. `baseMessage` is the past tick's pre-suffix output — the LLM's clean message, never its `formatted_message`. This is the snowball-prevention key: the suffix is built from `pastBaseMessage`, not from a past `formatted_message` that already contains a "Reminds me of" line. If the embed or lookup throws, fold the error into `errors` and proceed with no suffix. +6. Build `finalMessage = match !== null ? preMessage + "\n\nReminds me of: " + match.baseMessage : preMessage`. 7. Call `params.poster.post(finalMessage)`. If the post throws, fold the error into `errors`, skip the per-source inserts. -8. For each source in the per-source map, insert one `agent_notifications` row with the per-source `value`, `formatted_message = finalMessage`, `nearest_match_id = match?.notificationId ?? null`, `nearest_match_distance = match?.distance ?? null`, and the shared `posted_at`. Update `agent_sources` with the per-source last-value/last-fetched/last-posted timestamps. -9. **Embed the LLM's pre-suffix output and store it** under each source label: `insertEmbedding(db, notificationId, preVector)`. The corpus keys on the LLM's output, not the posted-with-suffix message — this is what makes the cosine match tight. Per-source embed/store failures are caught and folded into `errors`; the row stays. +8. For each source in the per-source map, insert one `agent_notifications` row with the per-source `value`, `formatted_message = finalMessage`, `base_message = preMessage`, `nearest_match_id = match?.notificationId ?? null`, `nearest_match_distance = match?.distance ?? null`, and the shared `posted_at`. Update `agent_sources` with the per-source last-value/last-fetched/last-posted timestamps. +9. **Embed the LLM's pre-suffix output and store it** under each source label: `insertEmbedding(db, notificationId, preVector)`. The corpus keys on `base_message`, not on `formatted_message` — this is what makes the cosine match tight and what prevents the snowball. Per-source embed/store failures are caught and folded into `errors`; the row stays. 10. Finish the `agent_runs` row with `notificationsSent: 1` (one combined post per tick, regardless of how many sources contributed). 11. Publish the snapshot (unchanged). The `runFetch` signature gains `weatherLocation: string` (so the writer can populate the context's `location` field). The result shape `RunFetchResult` is unchanged; `notificationsSent` is now `0 | 1` per tick. -### 4.5 `src/rag/similarity.ts` — global KNN +### 4.5 `src/rag/similarity.ts` — global KNN, returns `baseMessage` -`findNearestMatch(db, queryVector)` — the function loses its `source` parameter. The SQL removes the `source = ?` filter; the match is the single most similar notification across all sources. The return shape is unchanged: `{ notificationId, distance, formattedMessage, postedAt } | null`. +`findNearestMatch(db, queryVector)` — the function loses its `source` parameter. The SQL removes the `source = ?` filter; the match is the single most similar notification across all sources. The return shape changes from `{ notificationId, distance, formattedMessage, postedAt }` to `{ notificationId, distance, baseMessage, postedAt }` — the match returns the past tick's `base_message` (the LLM's pre-suffix output), not its `formatted_message`. The writer uses `baseMessage` for the "Reminds me of" suffix, which is what prevents the snowball. The function is now called once per tick (in step 5 above), not once per source. The KNN is global; the per-source labels in `agent_embeddings` are no longer used for filtering (they remain as the `notification_id → source` join path for the status endpoint, unchanged). @@ -226,8 +240,9 @@ The scripts also print the rule's current state on success so the user can see t Unit tests (vitest) for: -- `runFetch` happy path with RAG history: both sources succeed, formatter is called ONCE with a clean `LoopContext` (no RAG fields), the LLM's output is embedded, KNN returns a match, `finalMessage` includes the `\n\nReminds me of: ` suffix, Discord is posted ONCE, two `agent_notifications` rows are written (one per source) with the same `formatted_message` (including the suffix) and the same `nearest_match_id`, two `agent_embeddings` rows are inserted with the **pre-suffix** text. -- `runFetch` first-tick path (no RAG history): the LLM is called, the RAG lookup returns null (no past notifications), `finalMessage = preMessage` (no suffix), one combined Discord post, two notification rows with `nearest_match_id = null`. +- `runFetch` happy path with RAG history: both sources succeed, formatter is called ONCE with a clean `LoopContext` (no RAG fields), the LLM's output is embedded, KNN returns a match whose `baseMessage` is the past tick's pre-suffix text, `finalMessage` includes the `\n\nReminds me of: ` suffix, Discord is posted ONCE, two `agent_notifications` rows are written (one per source) with the same `formatted_message` (including the suffix), the same `base_message` (the pre-suffix text), and the same `nearest_match_id`, two `agent_embeddings` rows are inserted keyed on `base_message`. +- `runFetch` first-tick path (no RAG history): the LLM is called, the RAG lookup returns null (no past notifications), `finalMessage = preMessage` (no suffix), one combined Discord post, two notification rows with `formatted_message = base_message = preMessage` and `nearest_match_id = null`. +- **Snowball regression test (critical):** simulate 20 consecutive ticks where each past `formatted_message` is built by appending the prior tick's full `formatted_message` to a ~150-char base. Without the `base_message` separation, the 20th tick's `finalMessage` would exceed Discord's 2000-char limit and the test would assert a 400-style post failure. With the separation, each tick's `base_message` is always ~150 chars and the `finalMessage` is always bounded (base + one suffix of past base). The test pins this: 20 ticks, every post succeeds, every `finalMessage.length < 500`. - `runFetch` with one source failing (e.g., coingecko down): the other source still contributes; formatter receives `weatherValue: '72F'`, `cryptoValue: ''`; one notification row per successful source; `errors` includes the failed source. - `runFetch` with both sources failing: formatter is not called, no Discord post, no notification rows, `errors` includes both sources, `notificationsSent: 0`. - `runFetch` formatter failure: caught and folded into `errors`, no RAG lookup, no Discord post, no notification rows, snapshot is still published. @@ -258,5 +273,5 @@ Per the user instruction, no new `docs/0X-*.md` tutorial file. Two small changes - **RAG corpus bloat over long runs.** If the loop is ever left running for days, `agent_notifications` and `agent_embeddings` grow linearly (~576 rows/day each at 5-min cadence with two sources). A future spec could add a sliding-window cap or a vacuum job. This spec keeps the "every posted message is searchable forever" RAG invariant intact and relies on the short-lived nature of the loop in practice. - **Loop token rotation.** `LOOP_TOKEN` is auto-generated at synth time and stays stable for the life of the stack. A redeploy regenerates the token and updates the stack output. There's no manual override path — for a single-user tutorial this is the deliberate trade-off for not managing a secret by hand. If the token is leaked, the recovery is `npm run deploy` (which writes a new token). - **Per-source RAG metadata on the row.** With the new design, the `nearest_match_id` / `nearest_match_distance` columns are the same on all per-source rows of a tick (the single global RAG match for the combined message). The status endpoint will show the same match twice (once per source row). This is mildly redundant but not wrong; if a future spec wants to deduplicate the per-source rows into one row per tick, that's a schema change worth its own design. -- **"Reminds me of" message length.** The past message (the matched `formatted_message`, which itself may include a "Reminds me of" suffix) is appended verbatim. Discord's 2000-char limit caps the chain length. At 5-min cadence, a single past message is short enough that this is fine for any reasonable run length, but a future spec could truncate the suffix or strip nested "Reminds me of" lines. +- **"Reminds me of" message length.** With the `base_message` separation, the suffix is always built from the past tick's pre-suffix text (a single LLM output, ~150 chars), never from a past `formatted_message` that includes a prior "Reminds me of" line. The posted message is bounded at one base + one suffix (~300 chars typical), well under Discord's 2000-char limit. No chain growth, no truncation needed. - **CDK synth-time token across multi-env deploys.** `crypto.randomBytes(24)` runs at synth time, so the same `cdk.out` template deployed to multiple environments carries the same token. For a single-user tutorial this is acceptable; the documented behavior is "redeploy to rotate." From 17f2025b90752d1c1c8da100aec227f4cedd75a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:40:48 -0400 Subject: [PATCH 08/26] docs(spec): mark loop mode + poetic closing as approved --- docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index c5dad8c..bb3cc38 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -1,7 +1,7 @@ # Loop Mode + Poetic Closing — Design **Date:** 2026-08-09 -**Status:** Draft (pending user review) +**Status:** Approved **Scope:** Replaces the once-daily schedule with a 5-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + haiku, with a mechanical "Reminds me of: $similar" suffix that points at the closest past message), and adds commands to start and stop the loop. `loop-stop` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. One Discord message per tick (combined weather + crypto + haiku, plus optional "Reminds me of" suffix), no dedup, no new user-facing tutorial doc. The loop token is auto-generated by the CDK stack at synth time so the user doesn't have to manage a secret. --- From 993f4fa0c0eed43adb26edab1dce247012727ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:18:02 -0400 Subject: [PATCH 09/26] feat(schema): add base_message column for snowball-free RAG suffix --- src/db/bootstrap.ts | 24 ++++++++++++++++-------- tests/db.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/db/bootstrap.ts b/src/db/bootstrap.ts index f46c889..d404f5c 100644 --- a/src/db/bootstrap.ts +++ b/src/db/bootstrap.ts @@ -5,18 +5,23 @@ import { AGENT_DDL } from './schema.js'; * every writer invocation. */ export function bootstrap(db: Database.Database): void { db.exec(AGENT_DDL); - addNearestMatchColumnsIfMissing(db); + addMissingColumns(db); } /** - * SQLite has no `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so idempotency here is - * implemented by checking `PRAGMA table_info` first. These two columns record which - * past notification (if any) was the closest match at the time this notification was - * posted (RAG design spec §3.2) — both nullable, since `NULL` legitimately means "no - * prior notification for this source yet" or "the embedding/match step failed and was - * isolated" (spec §6), not a placeholder to special-case. + * SQLite has no `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so idempotency is + * implemented by checking `PRAGMA table_info` first. These columns record the + * past-notification relationship at the time this notification was posted: + * + * - `nearest_match_id` / `nearest_match_distance` — RAG design spec §3.2 + * - `base_message` — loop-mode + poetic-closing design spec §3. Stores the LLM's + * pre-suffix output (the friendly comment + haiku). The RAG corpus embeds this + * column, and `findNearestMatch` returns it for the "Reminds me of" suffix — + * never the posted `formatted_message` — so the suffix cannot snowball. + * Nullable so legacy rows post-migration carry `NULL` and the LIKE exclusion + * in `findNearestMatch` keeps them out of match candidacy. */ -function addNearestMatchColumnsIfMissing(db: Database.Database): void { +function addMissingColumns(db: Database.Database): void { const columns = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; const names = new Set(columns.map((c) => c.name)); @@ -28,4 +33,7 @@ function addNearestMatchColumnsIfMissing(db: Database.Database): void { if (!names.has('nearest_match_distance')) { db.exec(`ALTER TABLE agent_notifications ADD COLUMN nearest_match_distance REAL`); } + if (!names.has('base_message')) { + db.exec(`ALTER TABLE agent_notifications ADD COLUMN base_message TEXT`); + } } diff --git a/tests/db.test.ts b/tests/db.test.ts index 5eefb8e..0a2d759 100644 --- a/tests/db.test.ts +++ b/tests/db.test.ts @@ -213,4 +213,33 @@ describe('bootstrap — nearest_match columns', () => { db.close(); rmSync(dir, { recursive: true, force: true }); }); + + it('adds base_message to agent_notifications when missing, and is idempotent on re-run', () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-test-')); + const path = join(dir, 'memory.db'); + + const db = openDatabase(path); + bootstrap(db); + + // After bootstrap, base_message column exists and is nullable. + const cols = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string; notnull: number }>; + const base = cols.find((c) => c.name === 'base_message'); + expect(base).toBeDefined(); + expect(base?.notnull).toBe(0); + + // Inserting a row with base_message = null is allowed (legacy rows post-migration). + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + db.prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at, base_message) + VALUES ('weather', 'v', 'msg', 1000, NULL)`, + ).run(); + + // Re-running bootstrap must not throw and must not alter the column. + expect(() => bootstrap(db)).not.toThrow(); + const colsAfter = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; + expect(colsAfter.filter((c) => c.name === 'base_message')).toHaveLength(1); + + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); }); From 2d708ffe696d4e30eec9088fd6be77532c3aecfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:18:40 -0400 Subject: [PATCH 10/26] feat(rag): global KNN returns baseMessage, filter null base_message --- src/rag/similarity.ts | 57 +++++++++++++-------- tests/similarity.test.ts | 105 +++++++++++++++++++++++++++++++-------- 2 files changed, 118 insertions(+), 44 deletions(-) diff --git a/src/rag/similarity.ts b/src/rag/similarity.ts index f7f1323..3f245b3 100644 --- a/src/rag/similarity.ts +++ b/src/rag/similarity.ts @@ -1,63 +1,76 @@ import type Database from 'better-sqlite3'; -import type { SourceName } from '../db/schema.js'; +/** A match found by `findNearestMatch` — the past tick's pre-suffix output (the LLM's + * clean message, never its `formatted_message`). The writer uses `baseMessage` to + * build the "Reminds me of" suffix; using `formattedMessage` instead would let the + * suffix grow recursively and eventually blow the Discord 2000-char limit (loop-mode + * + poetic-closing spec §1, §4.5). */ export interface NearestMatch { notificationId: number; distance: number; - formattedMessage: string; + baseMessage: string; postedAt: number; } /** - * Fixed KNN scan size (RAG design spec §4.2) — generous for a once-daily tutorial's - * history (50 rows is ~7 weeks across two sources), documented as a known ceiling rather - * than engineered for arbitrary scale: past this ceiling, `findNearestMatch` can miss the - * true nearest same-source neighbor if it isn't among the 50 closest across *all* - * sources. Same pattern as `RECENT_NOTIFICATIONS_LIMIT` in `src/agent/status.ts`. + * Fixed KNN scan size (loop-mode + poetic-closing spec §4.5). At 5-min cadence, 2 sources, + * 1 row per source per tick, 50 candidates is roughly 2 hours of wall-clock history — + * a generous window for the loop's intended short-lived test runs (5–10 minutes), so the + * ceiling is unlikely to bind in practice. Past this ceiling, `findNearestMatch` can miss + * the true nearest neighbor if it isn't among the 50 closest across `agent_embeddings`. + * Same pattern as `RECENT_NOTIFICATIONS_LIMIT` in `src/agent/status.ts`. */ const KNN_CANDIDATES = 50; interface CandidateRow { notificationId: number; - source: string; - formattedMessage: string; + baseMessage: string; postedAt: number; distance: number; } /** - * Finds the closest same-source past notification to `queryVector`, or `null` if the - * source has no embedded history yet. `agent_embeddings` is a single table across all - * sources (RAG design spec §3.1) — same-source filtering happens here, in application - * code, rather than via a `sqlite-vec` partition key, to avoid depending on a - * less-battle-tested part of the extension's API for this tutorial (spec §4.2, §11). + * Finds the closest past notification to `queryVector` across all sources (global KNN, + * no per-source filter), or `null` if no eligible history exists. The `agent_embeddings` + * table is shared across sources (RAG design spec §3.1). + * + * `WHERE n.base_message IS NOT NULL` is applied as a post-filter on the top-`k` + * candidates — `sqlite-vec`'s `k` parameter operates on the raw vector scan, so this + * filter runs after the KNN. It is required: without it, legacy rows (post-migration + * `base_message = NULL`) would surface as `agent_embeddings` candidates whose joined + * `base_message` is null, and the writer would post the literal string `"null"` into + * the "Reminds me of" suffix. + * + * Step-ordering note: this scan runs *before* the current tick's `insertEmbedding` (which + * happens after the Discord post in `runFetch`), so the corpus at query time contains + * only notifications already posted by prior ticks. The current tick's own message + * cannot be its own match — no explicit age floor is needed to enforce that. */ -export function findNearestMatch(db: Database.Database, source: SourceName, queryVector: number[]): NearestMatch | null { +export function findNearestMatch(db: Database.Database, queryVector: number[]): NearestMatch | null { const rows = db .prepare( - `SELECT n.id AS notificationId, n.source AS source, n.formatted_message AS formattedMessage, - n.posted_at AS postedAt, e.distance AS distance - FROM agent_embeddings e + `SELECT n.id AS notificationId, n.base_message AS baseMessage, + n.posted_at AS postedAt, e.distance AS distance FROM agent_embeddings e JOIN agent_notifications n ON n.id = e.notification_id WHERE e.embedding MATCH ? AND k = ? + AND n.base_message IS NOT NULL ORDER BY e.distance`, ) .all(JSON.stringify(queryVector), KNN_CANDIDATES) as CandidateRow[]; - const match = rows.find((row) => row.source === source); + const match = rows[0]; if (match === undefined) return null; return { notificationId: match.notificationId, distance: match.distance, - formattedMessage: match.formattedMessage, + baseMessage: match.baseMessage, postedAt: match.postedAt, }; } /** Stores `vector` for `notificationId`, making it a future `findNearestMatch` - * candidate. Called once per posted notification (RAG design spec §3.1) — never for - * deduped/unchanged values. + * candidate. Called once per posted notification (RAG design spec §3.1). * * `notificationId` must be bound as a `BigInt`: binding it as a plain JS number trips * `vec0`'s "Only integers are allowed for primary key values" check in `better-sqlite3` diff --git a/tests/similarity.test.ts b/tests/similarity.test.ts index b739206..fd3f188 100644 --- a/tests/similarity.test.ts +++ b/tests/similarity.test.ts @@ -19,13 +19,20 @@ function cleanup(dir: string, db: Database.Database) { rmSync(dir, { recursive: true, force: true }); } -function insertNotification(db: Database.Database, source: string, formattedMessage: string, postedAt: number): number { +function insertNotification( + db: Database.Database, + source: string, + formattedMessage: string, + postedAt: number, + baseMessage: string | null = formattedMessage, +): number { const result = db .prepare( - `INSERT INTO agent_notifications (source, value, formatted_message, posted_at) - VALUES (?, 'v', ?, ?)`, + `INSERT INTO agent_notifications + (source, value, formatted_message, posted_at, base_message) + VALUES (?, 'v', ?, ?, ?)`, ) - .run(source, formattedMessage, postedAt); + .run(source, formattedMessage, postedAt, baseMessage); return Number(result.lastInsertRowid); } @@ -38,46 +45,99 @@ function unitVector(index: number): number[] { } describe('findNearestMatch', () => { - it('returns null when the source has no embedded history yet', () => { + it('returns null when there is no embedded history yet', () => { const { dir, db } = setup(); db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); - const result = findNearestMatch(db, 'weather', unitVector(0)); - expect(result).toBeNull(); + expect(findNearestMatch(db, unitVector(0))).toBeNull(); cleanup(dir, db); }); - it('returns the closest same-source notification by cosine distance', () => { + it('returns the closest notification (global, no per-source filter) and exposes baseMessage', () => { const { dir, db } = setup(); db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); - const closeId = insertNotification(db, 'weather', 'close message', 1000); + const closeId = insertNotification(db, 'weather', 'close message', 1000, 'close base'); insertEmbedding(db, closeId, unitVector(0)); - const farId = insertNotification(db, 'weather', 'far message', 2000); + const farId = insertNotification(db, 'weather', 'far message', 2000, 'far base'); insertEmbedding(db, farId, unitVector(1)); - const match = findNearestMatch(db, 'weather', unitVector(0)); + const match = findNearestMatch(db, unitVector(0)); expect(match).not.toBeNull(); expect(match?.notificationId).toBe(closeId); - expect(match?.formattedMessage).toBe('close message'); + expect(match?.baseMessage).toBe('close base'); expect(match?.postedAt).toBe(1000); - expect(match?.distance).toBeLessThan(0.01); // near-identical vector, near-zero distance + expect(match?.distance).toBeLessThan(0.01); cleanup(dir, db); }); - it('filters to the requested source even when another source has a closer vector', () => { + it('matches across sources (no per-source filter) — KNN is global', () => { const { dir, db } = setup(); db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); - const cryptoId = insertNotification(db, 'crypto', 'crypto message', 1000); - insertEmbedding(db, cryptoId, unitVector(0)); // exact match for the query vector below + const cryptoId = insertNotification(db, 'crypto', 'crypto base', 1000, 'crypto base'); + insertEmbedding(db, cryptoId, unitVector(0)); - const weatherId = insertNotification(db, 'weather', 'weather message', 2000); - insertEmbedding(db, weatherId, unitVector(5)); // far from the query vector + const weatherId = insertNotification(db, 'weather', 'weather base', 2000, 'weather base'); + insertEmbedding(db, weatherId, unitVector(5)); - const match = findNearestMatch(db, 'weather', unitVector(0)); - expect(match?.notificationId).toBe(weatherId); // not cryptoId, despite being the closer vector + const match = findNearestMatch(db, unitVector(0)); + expect(match?.notificationId).toBe(cryptoId); // crypto is closer than weather + expect(match?.baseMessage).toBe('crypto base'); + cleanup(dir, db); + }); + + it('excludes rows whose base_message is NULL (legacy rows post-migration)', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); + + // Three legacy rows with base_message = NULL, vectors spanning the search space. + // The closest vector candidate should be a CRYPTO row with NULL base_message — + // a non-null row elsewhere must still be returned if present. + const legacyCrypto = insertNotification(db, 'crypto', 'legacy crypto', 1000, null); + insertEmbedding(db, legacyCrypto, unitVector(0)); // closest to the query below + + const legacyWeather = insertNotification(db, 'weather', 'legacy weather', 1100, null); + insertEmbedding(db, legacyWeather, unitVector(1)); + + const valid = insertNotification(db, 'weather', 'valid posted', 900, 'valid base'); + insertEmbedding(db, valid, unitVector(10)); // far from unitVector(0) but non-null base_message + + const match = findNearestMatch(db, unitVector(0)); + expect(match?.notificationId).toBe(valid); + expect(match?.baseMessage).toBe('valid base'); + cleanup(dir, db); + }); + + it('returns null when every candidate has base_message = NULL', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); + + const legacy1 = insertNotification(db, 'weather', 'legacy', 1000, null); + insertEmbedding(db, legacy1, unitVector(0)); + + const legacy2 = insertNotification(db, 'crypto', 'legacy', 1100, null); + insertEmbedding(db, legacy2, unitVector(1)); + + expect(findNearestMatch(db, unitVector(0))).toBeNull(); + cleanup(dir, db); + }); + + it('matches a recent (few-minutes-old) past notification — no age floor', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + // Reference tick: 5 minutes ago. + const recent = insertNotification(db, 'weather', 'recent posted', 1000, 'recent base'); + insertEmbedding(db, recent, unitVector(0)); + + // Query "now" (no time gap engineered in): the function takes no timestamp, + // so any match from the corpus is valid — the no-age-floor decision is + // pinned by the absence of a timestamp filter, not by an explicit one. + const match = findNearestMatch(db, unitVector(0)); + expect(match?.notificationId).toBe(recent); + expect(match?.baseMessage).toBe('recent base'); cleanup(dir, db); }); }); @@ -86,12 +146,13 @@ describe('insertEmbedding', () => { it('stores a vector retrievable by a later findNearestMatch call', () => { const { dir, db } = setup(); db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); - const id = insertNotification(db, 'weather', 'stored message', 1000); + const id = insertNotification(db, 'weather', 'stored message', 1000, 'stored base'); expect(() => insertEmbedding(db, id, unitVector(3))).not.toThrow(); - const match = findNearestMatch(db, 'weather', unitVector(3)); + const match = findNearestMatch(db, unitVector(3)); expect(match?.notificationId).toBe(id); + expect(match?.baseMessage).toBe('stored base'); cleanup(dir, db); }); }); From a470937d62796792f8ed71b6bdd01e8a4f96febb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:18:48 -0400 Subject: [PATCH 11/26] refactor(format): introduce LoopContext, remove SimilarPastResult --- src/format/types.ts | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/format/types.ts b/src/format/types.ts index 7cb0984..72eb246 100644 --- a/src/format/types.ts +++ b/src/format/types.ts @@ -2,20 +2,33 @@ import type { SourceName } from '../db/schema.js'; export type { SourceName }; -/** The minimal shape `format()` needs from a RAG nearest-match lookup (see - * `src/rag/similarity.ts`'s richer `NearestMatch`) — kept separate so `format/` doesn't - * import from `rag/`; a `NearestMatch` is structurally assignable here since it has - * every field `SimilarPastResult` needs and more. */ -export interface SimilarPastResult { - formattedMessage: string; - postedAt: number; +/** One entry per source that fetched successfully this tick — source-agnostic, so a + * third source added per `docs/04-extending.md` shows up in the prompt automatically + * (loop-mode + poetic-closing spec §4.2). A failed source is absent from the array + * rather than represented as `''` — the LLM sees only sources it has + * real data for. */ +export interface LoopReading { + source: SourceName; + value: string; } -/** Turns a raw fetched value into a friendly Discord message. `LocalTemplateFormatter` - * (this PR) and `BedrockFormatter` (PR2) implement the same interface, so the writer's - * hot path does not change between local and deployed (spec §2). `similarPast`, when - * provided, is the closest same-source past notification (RAG design spec §4.3) — - * `null`/omitted means no history exists yet or the RAG lookup failed and was isolated. */ +/** The full set of inputs the formatter sees per tick. `date` and `location` are simple + * ambient context; `readings` carries the structured data the LLM should weave into + * its friendly comment and closing haiku. RAG is intentionally *not* in this shape: + * the LLM is not told about the closest past reading. The "Reminds me of" suffix is + * appended mechanically in the writer after the format call returns. */ +export interface LoopContext { + date: string; // ISO date, e.g. "2026-08-09" + location: string; // e.g. "NYC" + readings: LoopReading[]; // one entry per source that succeeded this tick +} + +/** Turns a `LoopContext` into a friendly Discord message. `LocalTemplateFormatter` and + * `BedrockFormatter` implement the same interface, so the writer's hot path does not + * change between local and deployed. The LLM is given `date`, `location`, and + * `readings`; the writer mechanically appends the "Reminds me of" suffix to the + * formatter's output after the RAG lookup, so the formatter never sees the closest + * past reading. */ export interface MessageFormatter { - format(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): Promise; + format(ctx: LoopContext): Promise; } From efb7b6a4e49216293417a5909eff77eae3ae80a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:19:03 -0400 Subject: [PATCH 12/26] refactor(format): local template uses LoopContext, drops LABELS --- src/format/local.ts | 19 +++++++++++-------- tests/format.test.ts | 35 ++++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/format/local.ts b/src/format/local.ts index 8e75f66..31156d3 100644 --- a/src/format/local.ts +++ b/src/format/local.ts @@ -1,13 +1,16 @@ -import type { MessageFormatter } from './types.js'; +import type { LoopContext, MessageFormatter } from './types.js'; -const LABELS = { weather: 'Weather update', crypto: 'Crypto update' } as const; - -/** Deterministic, no-AWS `MessageFormatter` for Phase 1 (spec §9). Never used in the - * deployed Lambda — `BedrockFormatter` (PR2) is the default there. */ +/** Deterministic, no-AWS `MessageFormatter` for local runs (spec §9). Emits a + * `"{date} — {location} — {source}: {value}, ..."` shape — one segment per reading + * in input order. Source-agnostic: a third source added per `docs/04-extending.md` + * shows up in the output automatically. Not expected to generate a haiku — it's a + * test-only stub. Never used in the deployed Lambda — `BedrockFormatter` is the + * default there (`src/handler.ts`). */ export function createLocalTemplateFormatter(): MessageFormatter { return { - async format(source, rawValue) { - return `${LABELS[source]}: ${rawValue}`; + async format(ctx: LoopContext): Promise { + const segments = ctx.readings.map((r) => `${r.source}: ${r.value}`).join(', '); + return `${ctx.date} — ${ctx.location} — ${segments}`; }, }; -} \ No newline at end of file +} diff --git a/tests/format.test.ts b/tests/format.test.ts index 55ae81d..7c4a3d5 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -2,23 +2,40 @@ import { describe, expect, it } from 'vitest'; import { createLocalTemplateFormatter } from '../src/format/local.js'; +const ctx = (readings: Array<{ source: 'weather' | 'crypto'; value: string }>, date = '2026-08-09', location = 'NYC') => ({ + date, + location, + readings, +}); + describe('LocalTemplateFormatter', () => { - it('formats a weather value deterministically', async () => { + it('formats a single weather reading with date and location', async () => { + const formatter = createLocalTemplateFormatter(); + const message = await formatter.format(ctx([{ source: 'weather', value: '72F' }])); + expect(message).toBe('2026-08-09 — NYC — weather: 72F'); + }); + + it('formats a single crypto reading with date and location', async () => { const formatter = createLocalTemplateFormatter(); - const message = await formatter.format('weather', '72F'); - expect(message).toBe('Weather update: 72F'); + const message = await formatter.format(ctx([{ source: 'crypto', value: '67234.10' }])); + expect(message).toBe('2026-08-09 — NYC — crypto: 67234.10'); }); - it('formats a crypto value deterministically', async () => { + it('joins multiple readings with comma separators', async () => { const formatter = createLocalTemplateFormatter(); - const message = await formatter.format('crypto', '67234.10'); - expect(message).toBe('Crypto update: 67234.10'); + const message = await formatter.format( + ctx([ + { source: 'weather', value: '72F' }, + { source: 'crypto', value: '67234.10' }, + ]), + ); + expect(message).toBe('2026-08-09 — NYC — weather: 72F, crypto: 67234.10'); }); it('produces the same output for the same input across calls', async () => { const formatter = createLocalTemplateFormatter(); - const first = await formatter.format('weather', '72F'); - const second = await formatter.format('weather', '72F'); + const first = await formatter.format(ctx([{ source: 'weather', value: '72F' }])); + const second = await formatter.format(ctx([{ source: 'weather', value: '72F' }])); expect(first).toBe(second); }); -}); \ No newline at end of file +}); From 80e15c6f24520c0be9d5abcb08bfd43f1422e2b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:19:32 -0400 Subject: [PATCH 13/26] feat(format): Bedrock formatter uses LoopContext, system prompt asks for haiku --- src/format/bedrock.ts | 48 +++++++++++++++++---------- tests/bedrock.test.ts | 76 +++++++++++++++++++++++-------------------- 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src/format/bedrock.ts b/src/format/bedrock.ts index 27c3a3b..5a88bd1 100644 --- a/src/format/bedrock.ts +++ b/src/format/bedrock.ts @@ -1,7 +1,6 @@ import { type BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime'; -import type { SourceName } from '../db/schema.js'; import { resolveFamily } from './families.js'; -import type { MessageFormatter, SimilarPastResult } from './types.js'; +import type { LoopContext, MessageFormatter } from './types.js'; export interface BedrockFormatterOptions { client: BedrockRuntimeClient; @@ -31,17 +30,30 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * The LLM writes a short friendly comment and a closing haiku. The LLM is *not* told + * about RAG — the "Reminds me of" suffix is appended mechanically in the writer after + * the format call returns (loop-mode + poetic-closing spec §4.3). Numerology is + * deliberately out: the LLM produces cliché numerology platitudes, while a haiku + * gives the model real creative room and reads as varied across runs. + */ const SYSTEM_PROMPT = - 'You write a single short, friendly Discord notification message announcing a new ' + - 'value for a tracked data source. Reply with the message text only — no quotes, no ' + - 'preamble, no markdown formatting. If a closest past reading is included below, you ' + - 'may naturally reference it if relevant, but you are not required to.'; + 'You write a short, friendly Discord message for a check-in bot that posts a ' + + 'combined snapshot of a few tracked values every few minutes. The user ' + + 'message below contains today\'s date, the location, and the current value ' + + 'of each tracked reading. Write a brief comment (one or two sentences) that ' + + 'draws on these inputs — vary your phrasing across runs; do not repeat the ' + + 'same template. End with a short haiku (three lines, 5-7-5 syllables) that ' + + 'weaves in the readings and the day\'s vibe. ' + + 'Reply with the message text only — no quotes, no preamble, no markdown.'; -function buildUserPrompt(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): string { - const base = `Source: ${source}\nNew value: ${rawValue}`; - if (similarPast === null || similarPast === undefined) return base; - const date = new Date(similarPast.postedAt).toISOString().slice(0, 10); - return `${base}\nClosest past reading (${date}): "${similarPast.formattedMessage}"`; +function buildUserPrompt(ctx: LoopContext): string { + const lines = [ + `Date: ${ctx.date}`, + `Location: ${ctx.location}`, + ...ctx.readings.map((r) => `${r.source}: ${r.value}`), + ]; + return lines.join('\n'); } /** Maps a Bedrock exception to a message naming the fix (spec §6, §12.4). */ @@ -69,8 +81,8 @@ function mapBedrockError(error: unknown, options: BedrockFormatterOptions): Erro case 'ResourceNotFoundException': return new Error( `Bedrock does not recognise the model id for ${where}. Check that bedrockModelId ` + - `is the base id with no inference-profile prefix — the prefix is supplied by ` + - `the family (spec §12.3). Underlying error: ${detail}`, + `is the base id with no inference-profile prefix — the prefix is supplied by the ` + + `family (spec §12.3). Underlying error: ${detail}`, { cause: error }, ); default: @@ -91,12 +103,12 @@ function isThrottlingOr5xx(error: unknown): boolean { * other exception is not retried — access and validation failures are not transient. */ export function createBedrockFormatter(options: BedrockFormatterOptions): MessageFormatter { - async function attempt(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): Promise { + async function attempt(ctx: LoopContext): Promise { const response = await options.client.send( new ConverseCommand({ modelId: composedModelId(options.modelId), system: [{ text: SYSTEM_PROMPT }], - messages: [{ role: 'user', content: [{ text: buildUserPrompt(source, rawValue, similarPast) }] }], + messages: [{ role: 'user', content: [{ text: buildUserPrompt(ctx) }] }], inferenceConfig: { maxTokens: options.maxOutputTokens }, }), ); @@ -109,14 +121,14 @@ export function createBedrockFormatter(options: BedrockFormatterOptions): Messag } return { - async format(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): Promise { + async format(ctx: LoopContext): Promise { try { - return await attempt(source, rawValue, similarPast); + return await attempt(ctx); } catch (error: unknown) { if (isThrottlingOr5xx(error)) { await delay(RETRY_DELAY_MS); try { - return await attempt(source, rawValue, similarPast); + return await attempt(ctx); } catch (retryError: unknown) { throw mapBedrockError(retryError, options); } diff --git a/tests/bedrock.test.ts b/tests/bedrock.test.ts index 845c0bb..3489312 100644 --- a/tests/bedrock.test.ts +++ b/tests/bedrock.test.ts @@ -2,6 +2,7 @@ import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-r import { mockClient } from 'aws-sdk-client-mock'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createBedrockFormatter } from '../src/format/bedrock.js'; +import type { LoopContext } from '../src/format/types.js'; const bedrock = mockClient(BedrockRuntimeClient); @@ -19,6 +20,12 @@ function emptyResponse() { }; } +const ctx: LoopContext = { + date: '2026-08-09', + location: 'NYC', + readings: [{ source: 'weather', value: '72F' }], +}; + describe('createBedrockFormatter', () => { beforeEach(() => bedrock.reset()); afterEach(() => bedrock.reset()); @@ -26,7 +33,7 @@ describe('createBedrockFormatter', () => { const client = new BedrockRuntimeClient({ region: 'us-east-1' }); it('calls Converse with the configured model id and returns the response text', async () => { - bedrock.on(ConverseCommand).resolves(textResponse('Looks like 72F today!')); + bedrock.on(ConverseCommand).resolves(textResponse('A short friendly comment.\n\nA haiku here.')); const formatter = createBedrockFormatter({ client, @@ -35,8 +42,8 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - const message = await formatter.format('weather', '72F'); - expect(message).toBe('Looks like 72F today!'); + const message = await formatter.format(ctx); + expect(message).toBe('A short friendly comment.\n\nA haiku here.'); const calls = bedrock.commandCalls(ConverseCommand); expect(calls[0]?.args[0].input?.modelId).toBe('zai.glm-4.7-flash'); @@ -55,7 +62,7 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - await formatter.format('weather', '72F'); + await formatter.format(ctx); const calls = bedrock.commandCalls(ConverseCommand); expect(calls[0]?.args[0].input?.modelId).toBe( @@ -63,8 +70,8 @@ describe('createBedrockFormatter', () => { ); }); - it('includes the closest past reading in the prompt when one is provided', async () => { - bedrock.on(ConverseCommand).resolves(textResponse('Similar to last time!')); + it('uses the haiku-instruction system prompt and includes the LoopContext fields in the user message', async () => { + bedrock.on(ConverseCommand).resolves(textResponse('ok')); const formatter = createBedrockFormatter({ client, @@ -73,32 +80,31 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - await formatter.format('weather', '73F', { - formattedMessage: 'Looks like 72F today!', - postedAt: Date.parse('2026-08-01T00:00:00Z'), + await formatter.format({ + date: '2026-08-09', + location: 'NYC', + readings: [ + { source: 'weather', value: '72F' }, + { source: 'crypto', value: '67234.10' }, + ], }); const calls = bedrock.commandCalls(ConverseCommand); - const userText = calls[0]?.args[0].input?.messages?.[0]?.content?.[0]?.text ?? ''; - expect(userText).toContain('Looks like 72F today!'); - expect(userText).toContain('2026-08-01'); - }); - - it('omits any past-reading line when nearestMatch is null or omitted', async () => { - bedrock.on(ConverseCommand).resolves(textResponse('No history yet!')); - - const formatter = createBedrockFormatter({ - client, - modelId: 'zai.glm-4.7-flash', - region: 'us-east-1', - maxOutputTokens: 512, - }); - - await formatter.format('weather', '73F', null); - - const calls = bedrock.commandCalls(ConverseCommand); - const userText = calls[0]?.args[0].input?.messages?.[0]?.content?.[0]?.text ?? ''; - expect(userText).not.toContain('Closest past reading'); + const input = calls[0]?.args[0].input; + + // System prompt asks for a haiku — the LLM is never told about RAG. + const systemText = input?.system?.[0]?.text ?? ''; + expect(systemText).toMatch(/haiku/i); + expect(systemText).not.toMatch(/closest past reading/i); + + // User message carries date, location, and per-source readings. + const userText = input?.messages?.[0]?.content?.[0]?.text ?? ''; + expect(userText).toContain('2026-08-09'); + expect(userText).toContain('NYC'); + expect(userText).toContain('weather'); + expect(userText).toContain('72F'); + expect(userText).toContain('crypto'); + expect(userText).toContain('67234.10'); }); it('throws a descriptive error on AccessDeniedException', async () => { @@ -111,7 +117,7 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - await expect(formatter.format('weather', '72F')).rejects.toThrow(/model access/i); + await expect(formatter.format(ctx)).rejects.toThrow(/model access/i); }); it('throws a descriptive error on ResourceNotFoundException naming the model id and region', async () => { @@ -124,8 +130,8 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - await expect(formatter.format('weather', '72F')).rejects.toThrow(/zai\.glm-4\.7-flash/); - await expect(formatter.format('weather', '72F')).rejects.toThrow(/us-east-1/); + await expect(formatter.format(ctx)).rejects.toThrow(/zai\.glm-4\.7-flash/); + await expect(formatter.format(ctx)).rejects.toThrow(/us-east-1/); }); it('throws on a malformed response with no content, no retry', async () => { @@ -138,7 +144,7 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - await expect(formatter.format('weather', '72F')).rejects.toThrow(/no text/i); + await expect(formatter.format(ctx)).rejects.toThrow(/no text/i); expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(1); }); @@ -155,7 +161,7 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - const message = await formatter.format('weather', '72F'); + const message = await formatter.format(ctx); expect(message).toBe('formatted after retry'); expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(2); }); @@ -170,7 +176,7 @@ describe('createBedrockFormatter', () => { maxOutputTokens: 512, }); - await expect(formatter.format('weather', '72F')).rejects.toThrow(/Throttl/); + await expect(formatter.format(ctx)).rejects.toThrow(/Throttl/); expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(2); }); }); From a992bd7afb110848ee1c24b99efaf531426ba106 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:20:46 -0400 Subject: [PATCH 14/26] feat(fetch): combined-message + two-step RAG + base_message separation --- src/agent/fetch.ts | 283 ++++++++++++++------ tests/fetch.test.ts | 610 +++++++++++++++++++++++++++++++------------- 2 files changed, 647 insertions(+), 246 deletions(-) diff --git a/src/agent/fetch.ts b/src/agent/fetch.ts index 08423cc..6ca085d 100644 --- a/src/agent/fetch.ts +++ b/src/agent/fetch.ts @@ -5,9 +5,9 @@ import { bootstrap } from '../db/bootstrap.js'; import { openDatabase } from '../db/open.js'; import type { Embedder } from '../embed/titan.js'; import type { DiscordPoster } from '../discord/poster.js'; -import type { MessageFormatter } from '../format/types.js'; +import type { LoopContext, MessageFormatter } from '../format/types.js'; import { findNearestMatch, insertEmbedding } from '../rag/similarity.js'; -import type { SourceFetcher } from '../sources/types.js'; +import type { SourceFetcher, SourceName } from '../sources/types.js'; import type { Store } from '../store/types.js'; import { finishRun, startRun } from './runLog.js'; @@ -19,6 +19,10 @@ export interface RunFetchParams { poster: DiscordPoster; formatter: MessageFormatter; embedder: Embedder; + /** The configured location (e.g. "NYC") — passed through to the `LoopContext` so the + * LLM can weave it into the friendly comment and haiku. Loop-mode + poetic-closing + * spec §4.4. */ + weatherLocation: string; runId?: string; now?: () => number; } @@ -31,14 +35,19 @@ export interface RunFetchResult { } /** - * The writer op (spec §3.1). Hydrates from the store, runs bootstrap, checks each - * configured source for a new value, formats and posts on change, records the run, and - * publishes the updated snapshot back to the store with a conditional write. + * The writer op (spec §3.1, loop-mode + poetic-closing spec §4.4). Hydrates from the + * store, runs bootstrap, fetches every source, formats ONCE with a `LoopContext`, + * embeds the LLM's pre-suffix output ONCE, runs a global KNN lookup, appends a + * mechanical "Reminds me of" suffix if a match exists, posts ONCE, writes per-source + * `agent_notifications` rows (each with the same combined `formatted_message` / + * `base_message` / `nearest_match_id`), reuses the pre-vector to insert per-source + * embeddings, and publishes the snapshot back with a conditional write. * - * Per-source failures (fetch, formatter, or Discord post) are caught individually and - * folded into `agent_runs.error`; the run still completes and outcome stays `'success'` - * (spec §6). Only a `PreconditionFailedError` from the final publish propagates — that is - * an abort, not a per-source failure (spec §4.2). + * Per-source failures (fetch only — there is no per-source formatter or post) + * are caught individually and folded into `agent_runs.error`; the run still completes + * and outcome stays `'success'`. Tick-level failures (formatter, embed, post) are + * caught and the rest of the tick is skipped. Only a `PreconditionFailedError` from + * the final publish propagates — that is an abort, not a tick-level failure. */ export async function runFetch(params: RunFetchParams): Promise { const runId = params.runId ?? randomUUID(); @@ -65,95 +74,179 @@ export async function runFetch(params: RunFetchParams): Promise }); const errors: string[] = []; - let notificationsSent = 0; - // Step 5: per-source loop. + // Step 5: per-source fetch. Per-source failures are caught individually; the live + // readings feed step 7's LoopContext. + const readings = new Map(); for (const source of params.sources) { try { - const rawValue = await source.fetch(); + readings.set(source.name, await source.fetch()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${source.name}: ${message}`); + } + } - const existingRow = db - .prepare(`SELECT last_value FROM agent_sources WHERE name = ?`) - .get(source.name) as { last_value: string | null } | undefined; - const lastValue = existingRow?.last_value ?? null; + // Step 6: if every source failed, the rest of the tick is a no-op. + if (readings.size === 0) { + finishRun(db, { + runId, + endedAt: now(), + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; ') || null, + }); + db.close(); - if (rawValue === lastValue) { - continue; // dedup: no formatter call, no post, no notification row - } + const body = readFileSync(params.dbPath); + try { + await params.store.put(params.storeKey, body, priorEtag); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const dbForFailure = openDatabase(params.dbPath); + finishRun(dbForFailure, { + runId, + endedAt: now(), + outcome: 'error', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.length > 0 ? `${errors.join('; ')}; ${message}` : message, + }); + dbForFailure.close(); + throw error; + } - // RAG query step: find the closest same-source past notification. Failure here is - // isolated — it degrades to "no similarity context this run" (same as a source's - // first-ever notification), it never blocks the post itself (spec §6). - let match: Awaited> = null; - try { - const queryVector = await params.embedder.embed(rawValue); - match = findNearestMatch(db, source.name, queryVector); - } catch (embedError: unknown) { - const message = embedError instanceof Error ? embedError.message : String(embedError); - errors.push(`${source.name} (embedding query): ${message}`); - } + return { + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; ') || null, + }; + } + + // Step 7: build LoopContext and format ONCE (no RAG fields). A formatter failure + // skips the rest of the tick. + const loopContext: LoopContext = { + date: new Date(now()).toISOString().slice(0, 10), + location: params.weatherLocation, + readings: [...readings.entries()].map(([source, value]) => ({ source, value })), + }; - const formatted = await params.formatter.format(source.name, rawValue, match); - await params.poster.post(formatted); - - const postedAt = now(); - // Insert into agent_sources first — agent_notifications has a FK on source, so a - // notifications insert on a brand-new source would violate the constraint. - db.prepare( - `INSERT INTO agent_sources (name, last_value, last_fetched_at, last_posted_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - last_value = excluded.last_value, - last_fetched_at = excluded.last_fetched_at, - last_posted_at = excluded.last_posted_at`, - ).run(source.name, rawValue, postedAt, postedAt); - - const insertResult = db - .prepare( - `INSERT INTO agent_notifications - (source, value, formatted_message, posted_at, nearest_match_id, nearest_match_distance) - VALUES (?, ?, ?, ?, ?, ?)`, - ) - .run(source.name, rawValue, formatted, postedAt, match?.notificationId ?? null, match?.distance ?? null); - - // RAG store step: embed what was actually posted and make it a future match - // candidate. Failure here is isolated too — the notification has already - // committed; only the corpus fails to grow by this one entry (spec §6). + let preMessage: string; + try { + preMessage = await params.formatter.format(loopContext); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`formatter: ${message}`); + finishRun(db, { + runId, + endedAt: now(), + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; '), + }); + db.close(); + return publish(db, params, priorEtag, runId, now, 0, errors); + } + + // Step 8: two-step RAG. Embed the LLM's pre-suffix output ONCE for the tick, then + // run a global KNN lookup. A failure here skips the suffix but keeps the post. + let preVector: number[] | null = null; + type RAGMatch = { notificationId: number; distance: number; baseMessage: string; postedAt: number }; + let match: RAGMatch | null = null; + try { + preVector = await params.embedder.embed(preMessage); + match = findNearestMatch(db, preVector); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`rag: ${message}`); + // preVector stays null — step 13's embedding insert is skipped for this tick. + } + + // Step 9: build the final message. The suffix is built from past base_message + // (never past formatted_message), so the chain cannot snowball. + const finalMessage = + match !== null ? `${preMessage}\n\nReminds me of: ${match.baseMessage}` : preMessage; + + // Step 10: post once. + try { + await params.poster.post(finalMessage); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`post: ${message}`); + finishRun(db, { + runId, + endedAt: now(), + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; '), + }); + db.close(); + return publish(db, params, priorEtag, runId, now, 0, errors); + } + + // Step 11: per-source DB writes. agent_sources must be upserted BEFORE + // agent_notifications, because the latter has a FK on the former. + const postedAt = now(); + for (const [sourceName, value] of readings) { + db.prepare( + `INSERT INTO agent_sources (name, last_value, last_fetched_at, last_posted_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + last_value = excluded.last_value, + last_fetched_at = excluded.last_fetched_at, + last_posted_at = excluded.last_posted_at`, + ).run(sourceName, value, postedAt, postedAt); + + const insertResult = db + .prepare( + `INSERT INTO agent_notifications + (source, value, formatted_message, base_message, posted_at, + nearest_match_id, nearest_match_distance) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + sourceName, + value, + finalMessage, + preMessage, + postedAt, + match?.notificationId ?? null, + match?.distance ?? null, + ); + + // Step 12: per-source embedding insert. Reuses preVector — no second Titan call. + // Per-source insert failures are caught individually so the notification row + // committed in step 11 stays. + if (preVector !== null) { try { - const storeVector = await params.embedder.embed(formatted); - insertEmbedding(db, Number(insertResult.lastInsertRowid), storeVector); + insertEmbedding(db, Number(insertResult.lastInsertRowid), preVector); } catch (storeError: unknown) { const message = storeError instanceof Error ? storeError.message : String(storeError); - errors.push(`${source.name} (embedding store): ${message}`); + errors.push(`${sourceName} (embedding store): ${message}`); } - - notificationsSent++; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - errors.push(`${source.name}: ${message}`); } } - // Step 6: finish the run record. + // Step 13: finish the run record. One combined post per tick, regardless of how many + // sources contributed. const errorText = errors.length > 0 ? errors.join('; ') : null; finishRun(db, { runId, endedAt: now(), outcome: 'success', sourcesChecked: params.sources.length, - notificationsSent, + notificationsSent: 1, error: errorText, }); db.close(); - // Step 7: conditional publish. A PreconditionFailedError here is an abort, not a - // per-source failure (spec §4.2). The local DB is updated to outcome: 'error' before - // the error propagates, but because the conditional write failed, this update only - // lives in `/tmp/memory.db` and is never persisted to the store. The next invocation - // hydrates the prior snapshot and won't see this row — the error is recorded for - // postmortem (a debugger reading the local file or the in-process logs) but is not - // visible to a fresh reader. + // Step 14: conditional publish. A PreconditionFailedError here is an abort, not a + // tick-level failure (spec §4.2). const body = readFileSync(params.dbPath); try { await params.store.put(params.storeKey, body, priorEtag); @@ -165,7 +258,7 @@ export async function runFetch(params: RunFetchParams): Promise endedAt: now(), outcome: 'error', sourcesChecked: params.sources.length, - notificationsSent, + notificationsSent: 1, error: errorText === null ? message : `${errorText}; ${message}`, }); dbForFailure.close(); @@ -175,7 +268,47 @@ export async function runFetch(params: RunFetchParams): Promise return { outcome: 'success', sourcesChecked: params.sources.length, - notificationsSent, + notificationsSent: 1, error: errorText, }; } + +/** + * Short-circuits the publish step for tick-level failures — reopens the DB, closes it, + * and returns the standard RunFetchResult. The early-return paths in `runFetch` use + * this so the conditional-publish logic only lives in one place. + */ +function publish( + db: Database.Database, + params: RunFetchParams, + priorEtag: string | null, + runId: string, + now: () => number, + notificationsSent: number, + errors: string[], +): Promise { + db.close(); + const body = readFileSync(params.dbPath); + return params.store + .put(params.storeKey, body, priorEtag) + .then(() => ({ + outcome: 'success' as const, + sourcesChecked: params.sources.length, + notificationsSent, + error: errors.length > 0 ? errors.join('; ') : null, + })) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const dbForFailure = openDatabase(params.dbPath); + finishRun(dbForFailure, { + runId, + endedAt: now(), + outcome: 'error', + sourcesChecked: params.sources.length, + notificationsSent, + error: errors.length > 0 ? `${errors.join('; ')}; ${message}` : message, + }); + dbForFailure.close(); + throw error; + }); +} diff --git a/tests/fetch.test.ts b/tests/fetch.test.ts index 1e7afe7..f117874 100644 --- a/tests/fetch.test.ts +++ b/tests/fetch.test.ts @@ -8,9 +8,12 @@ import { openDatabase } from '../src/db/open.js'; import { createLocalStore } from '../src/store/local.js'; import { createLocalTemplateFormatter } from '../src/format/local.js'; import { createLocalEmbedder } from '../src/embed/local.js'; +import type { LoopContext } from '../src/format/types.js'; +import type { MessageFormatter } from '../src/format/types.js'; import { runFetch } from '../src/agent/fetch.js'; import { fakeSourceFetcher, throwingSourceFetcher } from './helpers/fakeSourceFetcher.js'; import { fakeDiscordPoster } from './helpers/fakeDiscordPoster.js'; +import type { Embedder } from '../src/embed/titan.js'; function setup() { const dir = mkdtempSync(join(tmpdir(), 'agent-fetch-test-')); @@ -23,6 +26,21 @@ function setup() { }; } +/** A `MessageFormatter` that records every `format()` call and returns a fixed + * ~150-char `preMessage`. Useful for asserting exact call counts and for the + * snowball regression test (every tick produces the same base message, so every + * tick's RAG lookup matches the previous tick). */ +function recordingFixedFormatter(preMessage = 'A short friendly comment. Today the weather is 72F and BTC is 67234.10. The vibe is calm, the city is bright, the work goes on.'): MessageFormatter & { calls: LoopContext[] } { + const calls: LoopContext[] = []; + return { + calls, + async format(ctx: LoopContext): Promise { + calls.push(ctx); + return preMessage; + }, + }; +} + describe('runFetch', () => { let ctx: ReturnType; @@ -30,117 +48,353 @@ describe('runFetch', () => { ctx = setup(); }); - it('bootstraps on first run: creates tables, inserts a notification, publishes to the store', async () => { - let formatCalls = 0; - const formatter = createLocalTemplateFormatter(); - const countingFormatter = { - async format(source: 'weather' | 'crypto', value: string) { - formatCalls++; - return formatter.format(source, value); + it('happy path with RAG history: one combined post, two notification rows, two embeddings, embed called once', async () => { + // Both ticks use the same recordingFixedFormatter so each tick's preMessage + // is identical and the second tick's RAG lookup is guaranteed to match the + // first tick's embeddings (same vector → cosine distance ≈ 0). The character- + // code-hash local embedder differentiates by string content, so two different + // preMessages would produce different vectors and the second tick could miss + // the first tick's embeddings. + const formatter = recordingFixedFormatter(); + const realEmbedder = createLocalEmbedder(); + let embedCalls = 0; + const wrappedEmbedder: Embedder = { + async embed(text: string) { + embedCalls++; + return realEmbedder.embed(text); }, }; + + // First tick seeds the corpus. + const first = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster: fakeDiscordPoster(), + formatter, + embedder: wrappedEmbedder, + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + expect(first.outcome).toBe('success'); + expect(first.notificationsSent).toBe(1); + + const embedCallsAfterFirst = embedCalls; + + // Second tick is the one we assert against. const poster = fakeDiscordPoster(); const result = await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', - sources: [fakeSourceFetcher('weather', ['72F'])], + sources: [ + fakeSourceFetcher('weather', ['73F']), + fakeSourceFetcher('crypto', ['67500.00']), + ], poster, - formatter: countingFormatter, + formatter, + embedder: wrappedEmbedder, + weatherLocation: 'Brooklyn', + runId: 'r2', + now: () => 2000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(1); + + // Formatter called once per tick — two calls total, the second tick's call is what + // we pin the LoopContext shape against. + expect(formatter.calls).toHaveLength(2); + const r2Ctx = formatter.calls[1]; + expect(r2Ctx?.date).toBe('1970-01-01'); // epoch date — this stub doesn't override `now` + expect(r2Ctx?.location).toBe('Brooklyn'); + expect(r2Ctx?.readings.map((r) => ({ source: r.source, value: r.value }))).toEqual([ + { source: 'weather', value: '73F' }, + { source: 'crypto', value: '67500.00' }, + ]); + + // Discord posted exactly once on the second tick; the message is the recorder's + // output with the suffix. + expect(poster.posted).toHaveLength(1); + expect(poster.posted[0]).toMatch(/^A short friendly comment\./); + expect(poster.posted[0]).toContain('Reminds me of:'); + + // The embed call runs once per tick (not per source) — one call on r1, one on r2. + expect(embedCalls - embedCallsAfterFirst).toBe(1); + + // DB has two agent_notifications rows per tick (one per source) with the same + // combined message and the same RAG match. + const reopened = openDatabase(ctx.dbPath); + const rows = reopened + .prepare( + `SELECT source, value, formatted_message, base_message, posted_at, + nearest_match_id, nearest_match_distance + FROM agent_notifications WHERE posted_at = 2000 ORDER BY source`, + ) + .all() as Array<{ + source: string; + value: string; + formatted_message: string; + base_message: string; + posted_at: number; + nearest_match_id: number | null; + nearest_match_distance: number | null; + }>; + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.formatted_message).toBe(poster.posted[0]); + expect(row.base_message).toBe( + 'A short friendly comment. Today the weather is 72F and BTC is 67234.10. The vibe is calm, the city is bright, the work goes on.', + ); + expect(row.nearest_match_id).not.toBeNull(); + expect(row.posted_at).toBe(2000); + } + + // Both notification rows share the same nearest_match_id (the global match). + expect(rows[0]?.nearest_match_id).toBe(rows[1]?.nearest_match_id); + + // Two embeddings for each tick (one per source), keyed on base_message. + const embCount = (reopened.prepare(`SELECT COUNT(*) AS c FROM agent_embeddings`).get() as { c: number }).c; + expect(embCount).toBe(4); // 2 from r1 + 2 from r2 + reopened.close(); + ctx.cleanup(); + }); + + it('first-tick path: no RAG history, posted message = preMessage (no suffix)', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter, embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); expect(result.outcome).toBe('success'); expect(result.notificationsSent).toBe(1); - expect(poster.posted).toEqual(['Weather update: 72F']); - expect(formatCalls).toBe(1); + expect(poster.posted).toHaveLength(1); + expect(poster.posted[0]).not.toContain('Reminds me of'); - // Published to the store. - const stored = await ctx.store.get('memory.db'); - expect(stored).not.toBeNull(); + const reopened = openDatabase(ctx.dbPath); + const rows = reopened + .prepare(`SELECT formatted_message, base_message, nearest_match_id FROM agent_notifications`) + .all() as Array<{ formatted_message: string; base_message: string; nearest_match_id: number | null }>; + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.formatted_message).toBe(row.base_message); + expect(row.nearest_match_id).toBeNull(); + } + reopened.close(); ctx.cleanup(); }); - it('dedup: unchanged value skips post, formatter call, and notification row', async () => { - // Pre-seed agent_sources.last_value = '72F'. - const db = openDatabase(ctx.dbPath); - bootstrap(db); - db.prepare( - `INSERT INTO agent_sources (name, last_value) VALUES ('weather', '72F')`, - ).run(); - db.close(); - await ctx.store.put('memory.db', readFileSync(ctx.dbPath), null); - - let formatCalls = 0; + it('brand-new source: agent_sources upsert runs before agent_notifications insert (no FK violation)', async () => { + // Fresh DB: no `agent_sources` rows exist yet for 'weather' or 'crypto'. The first + // tick on each is the brand-new path. The writer must upsert agent_sources before + // inserting agent_notifications because of the FK on agent_notifications.source. + const formatter = createLocalTemplateFormatter(); const poster = fakeDiscordPoster(); const result = await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', - sources: [fakeSourceFetcher('weather', ['72F'])], + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], poster, - formatter: { - async format(source, value) { - formatCalls++; - return `${source}: ${value}`; - }, - }, + formatter, embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); expect(result.outcome).toBe('success'); - expect(result.notificationsSent).toBe(0); - expect(poster.posted).toEqual([]); - expect(formatCalls).toBe(0); + const reopened = openDatabase(ctx.dbPath); + const sources = reopened.prepare(`SELECT name FROM agent_sources ORDER BY name`).all() as Array<{ name: string }>; + expect(sources.map((s) => s.name).sort()).toEqual(['crypto', 'weather']); + const notifications = reopened.prepare(`SELECT source FROM agent_notifications`).all() as Array<{ source: string }>; + expect(notifications.map((n) => n.source).sort()).toEqual(['crypto', 'weather']); + reopened.close(); ctx.cleanup(); }); - it('dedup on real change: inserts exactly one notification and updates last_value', async () => { + it('legacy-corpus RAG: skips null base_message rows even when they are the closest vectors', async () => { + // Seed with three notifications whose base_message is NULL (legacy post-migration). const db = openDatabase(ctx.dbPath); bootstrap(db); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); + + const vector = new Array(256).fill(0); + vector[0] = 1; + + const legacy1 = db + .prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at, base_message) + VALUES ('crypto', 'v', 'legacy crypto', 1000, NULL)`, + ) + .run(); + const insertEmbedding = (await import('../src/rag/similarity.js')).insertEmbedding; + insertEmbedding(db, Number(legacy1.lastInsertRowid), vector); // exact match for query + db.prepare( - `INSERT INTO agent_sources (name, last_value) VALUES ('weather', '72F')`, + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at, base_message) + VALUES ('weather', 'v', 'legacy weather', 1100, NULL)`, ).run(); db.close(); await ctx.store.put('memory.db', readFileSync(ctx.dbPath), null); + const formatter = recordingFixedFormatter(); const poster = fakeDiscordPoster(); - await runFetch({ + const result = await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', sources: [fakeSourceFetcher('weather', ['73F'])], poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 2000, + }); + + expect(result.outcome).toBe('success'); + // No valid match in the corpus → no suffix. + expect(poster.posted[0]).not.toContain('Reminds me of'); + expect(poster.posted[0]).not.toContain('null'); + ctx.cleanup(); + }); + + it('snowball regression: 20 consecutive ticks with the same fixed preMessage stay bounded', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + const embedder = createLocalEmbedder(); + + let now = 1000; + for (let i = 0; i < 20; i++) { + const tickStart = now; + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [ + fakeSourceFetcher('weather', ['72F']), + fakeSourceFetcher('crypto', ['67234.10']), + ], + poster, + formatter, + embedder, + weatherLocation: 'NYC', + runId: `r${i}`, + now: () => tickStart, + }); + now += 5 * 60 * 1000; // 5 minutes per tick + } + + expect(poster.posted).toHaveLength(20); + for (const message of poster.posted) { + expect(message.length).toBeLessThan(500); // well under Discord's 2000-char limit + // The suffix, if present, is built from a past base_message — never a past + // formatted_message — so the chain never grows. + expect(message).not.toMatch(/Reminds me of:.*Reminds me of:/); + } + ctx.cleanup(); + }); + + it('matches a recent past tick (no age floor)', async () => { + // Seed one tick. + const first = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster: fakeDiscordPoster(), formatter: createLocalTemplateFormatter(), embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); + expect(first.notificationsSent).toBe(1); - const reopened = openDatabase(ctx.dbPath); - const notifications = reopened - .prepare(`SELECT * FROM agent_notifications`) - .all() as Array>; - expect(notifications).toHaveLength(1); - expect(notifications[0]?.formatted_message).toBeTruthy(); - - const sourceRow = reopened - .prepare(`SELECT last_value, last_posted_at FROM agent_sources WHERE name = 'weather'`) - .get() as { last_value: string; last_posted_at: number }; - expect(sourceRow.last_value).toBe('73F'); - expect(sourceRow.last_posted_at).toBe(1000); - reopened.close(); + // Second tick a few "minutes" later — the past tick is fresh, but still a valid match. + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['73F']), fakeSourceFetcher('crypto', ['67500.00'])], + poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r2', + now: () => 1000 + 5 * 60 * 1000, // 5 minutes later + }); + + expect(poster.posted[0]).toContain('Reminds me of:'); + ctx.cleanup(); + }); + + it('runFetch never matches its own tick (lookup before insert)', async () => { + // The RAG lookup runs before this tick's agent_embeddings row is inserted — so + // the corpus at query time cannot contain the current tick's own message. Verified + // by driving r1 with a fixed preMessage 'A' and r2 with a different fixed preMessage + // 'B'. If the lookup ran after the insert, r2's suffix would reference 'B' (its own + // base_message, distance 0). With the correct ordering, the suffix references 'A'. + const formatter1 = recordingFixedFormatter('first tick message — A'); + const poster = fakeDiscordPoster(); + + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter: formatter1, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + poster.posted.length = 0; + const formatter2 = recordingFixedFormatter('second tick message — B'); + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['73F']), fakeSourceFetcher('crypto', ['67500.00'])], + poster, + formatter: formatter2, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r2', + now: () => 2000, + }); + + expect(poster.posted).toHaveLength(1); + // The suffix references the r1 base_message, not the r2 one. + expect(poster.posted[0]).toContain('Reminds me of: first tick message — A'); + expect(poster.posted[0]).not.toContain('Reminds me of: second tick message — B'); ctx.cleanup(); }); - it('partial-source failure does not poison the run: error recorded, other source posts, outcome success', async () => { + it('one source failing: the other contributes; formatter receives only the live reading', async () => { + const formatter = recordingFixedFormatter(); const poster = fakeDiscordPoster(); const result = await runFetch({ @@ -152,213 +406,227 @@ describe('runFetch', () => { fakeSourceFetcher('crypto', ['67234.10']), ], poster, - formatter: createLocalTemplateFormatter(), + formatter, embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); expect(result.outcome).toBe('success'); expect(result.notificationsSent).toBe(1); - expect(poster.posted).toEqual(['Crypto update: 67234.10']); + expect(formatter.calls[0]?.readings.map((r) => r.source)).toEqual(['crypto']); + expect(poster.posted).toHaveLength(1); const reopened = openDatabase(ctx.dbPath); const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; expect(run.error).toMatch(/weather/); - expect(run.error).toMatch(/fetch timeout/); + const notifications = reopened.prepare(`SELECT source FROM agent_notifications`).all() as Array<{ source: string }>; + expect(notifications).toEqual([{ source: 'crypto' }]); reopened.close(); ctx.cleanup(); }); - it('LLM/formatter failure for one source does not poison the run', async () => { + it('all sources failing: formatter not called, no post, no notifications, notificationsSent = 0', async () => { + const formatter = recordingFixedFormatter(); const poster = fakeDiscordPoster(); - const failingFormatter = { - async format(source: 'weather' | 'crypto', value: string) { - if (source === 'weather') throw new Error('formatter exploded'); - return `Crypto update: ${value}`; - }, - }; const result = await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', sources: [ - fakeSourceFetcher('weather', ['72F']), - fakeSourceFetcher('crypto', ['67234.10']), + throwingSourceFetcher('weather', 'fetch timeout'), + throwingSourceFetcher('crypto', 'rate limited'), ], poster, - formatter: failingFormatter, + formatter, embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); expect(result.outcome).toBe('success'); - expect(poster.posted).toEqual(['Crypto update: 67234.10']); + expect(result.notificationsSent).toBe(0); + expect(formatter.calls).toHaveLength(0); + expect(poster.posted).toHaveLength(0); const reopened = openDatabase(ctx.dbPath); - const notifications = reopened.prepare(`SELECT source FROM agent_notifications`).all() as Array<{ source: string }>; - expect(notifications).toEqual([{ source: 'crypto' }]); + const notifications = reopened.prepare(`SELECT * FROM agent_notifications`).all(); + expect(notifications).toHaveLength(0); reopened.close(); ctx.cleanup(); }); - it('conditional write 412 is honored: no upload, previous version untouched, run marked error', async () => { - // First run publishes v1. - await runFetch({ + it('formatter failure: caught, no RAG, no post, no notifications, snapshot published', async () => { + const poster = fakeDiscordPoster(); + const failingFormatter: MessageFormatter = { + async format(): Promise { + throw new Error('LLM exploded'); + }, + }; + + const result = await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', - sources: [fakeSourceFetcher('weather', ['72F'])], - poster: fakeDiscordPoster(), - formatter: createLocalTemplateFormatter(), + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter: failingFormatter, embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); - const v1 = await ctx.store.get('memory.db'); - - // Simulate a concurrent writer: corrupt the etag the second run will present by - // publishing again out-of-band so the second run's captured hydrate etag goes stale. - await ctx.store.put('memory.db', Buffer.concat([v1!.body, Buffer.from('x')]), v1!.etag); - - // Second run hydrates *before* the interleaving write above by using a store wrapper - // that returns the stale etag captured at hydrate time — simulated directly by - // constructing the fetch call with a store whose get() returns v1's stale etag. - const staleStore = { - ...ctx.store, - async get() { - return v1; - }, - }; - - await expect( - runFetch({ - dbPath: ctx.dbPath, - store: staleStore, - storeKey: 'memory.db', - sources: [fakeSourceFetcher('weather', ['73F'])], - poster: fakeDiscordPoster(), - formatter: createLocalTemplateFormatter(), - embedder: createLocalEmbedder(), - runId: 'r2', - now: () => 2000, - }), - ).rejects.toThrow(/PreconditionFailed/); - const current = await ctx.store.get('memory.db'); - expect(current?.body.length).toBe(v1!.body.length + 1); // untouched by the failed r2 attempt + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(0); + expect(poster.posted).toHaveLength(0); const reopened = openDatabase(ctx.dbPath); - const runRow = reopened.prepare(`SELECT outcome, error FROM agent_runs WHERE run_id = 'r2'`).get() as { outcome: string; error: string }; - expect(runRow.outcome).toBe('error'); - expect(runRow.error).toMatch(/PreconditionFailed/); + const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; + expect(run.error).toMatch(/LLM exploded/); + const notifications = reopened.prepare(`SELECT * FROM agent_notifications`).all(); + expect(notifications).toHaveLength(0); reopened.close(); ctx.cleanup(); }); - it('records nearest_match_id/nearest_match_distance pointing at a prior same-source notification', async () => { - await runFetch({ + it('Titan embed failure: caught, post still happens with no suffix, embedder called once total, no agent_embeddings this tick', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + let embedCalls = 0; + const failingEmbedder: Embedder = { + async embed(): Promise { + embedCalls++; + throw new Error('Titan throttled'); + }, + }; + + const result = await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', - sources: [fakeSourceFetcher('weather', ['72F'])], - poster: fakeDiscordPoster(), - formatter: createLocalTemplateFormatter(), - embedder: createLocalEmbedder(), + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter, + embedder: failingEmbedder, + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); - await runFetch({ - dbPath: ctx.dbPath, - store: ctx.store, - storeKey: 'memory.db', - sources: [fakeSourceFetcher('weather', ['75F'])], - poster: fakeDiscordPoster(), - formatter: createLocalTemplateFormatter(), - embedder: createLocalEmbedder(), - runId: 'r2', - now: () => 2000, - }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(1); + expect(poster.posted).toHaveLength(1); + expect(poster.posted[0]).not.toContain('Reminds me of'); + + // The embedder was called exactly once (the failed call), not a second time as a + // fallback. This pins the spec's "no second Titan call" rule for the RAG failure path. + expect(embedCalls).toBe(1); const reopened = openDatabase(ctx.dbPath); const rows = reopened - .prepare(`SELECT id, nearest_match_id, nearest_match_distance FROM agent_notifications ORDER BY id`) - .all() as Array<{ id: number; nearest_match_id: number | null; nearest_match_distance: number | null }>; + .prepare(`SELECT nearest_match_id, base_message FROM agent_notifications`) + .all() as Array<{ nearest_match_id: number | null; base_message: string }>; expect(rows).toHaveLength(2); - expect(rows[0]?.nearest_match_id).toBeNull(); // first-ever weather notification: no history to match - expect(rows[0]?.nearest_match_distance).toBeNull(); - expect(rows[1]?.nearest_match_id).toBe(rows[0]?.id); - expect(typeof rows[1]?.nearest_match_distance).toBe('number'); + for (const row of rows) { + expect(row.nearest_match_id).toBeNull(); + // The fixed formatter always returns the same string — no need to re-invoke it. + expect(row.base_message).toBe( + 'A short friendly comment. Today the weather is 72F and BTC is 67234.10. The vibe is calm, the city is bright, the work goes on.', + ); + } + const embCount = (reopened.prepare(`SELECT COUNT(*) AS c FROM agent_embeddings`).get() as { c: number }).c; + expect(embCount).toBe(0); // no pre-vector → no insert reopened.close(); ctx.cleanup(); }); - it('does not match a different source\'s prior notification (same-source filtering)', async () => { - await runFetch({ + it('post failure: caught, no per-source rows, no embeddings, snapshot published', async () => { + const formatter = recordingFixedFormatter(); + const throwingPoster = { + async post(): Promise { + throw new Error('Discord 500'); + }, + }; + + const result = await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', - sources: [fakeSourceFetcher('weather', ['72F'])], - poster: fakeDiscordPoster(), - formatter: createLocalTemplateFormatter(), + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster: throwingPoster, + formatter, embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); - await runFetch({ - dbPath: ctx.dbPath, - store: ctx.store, - storeKey: 'memory.db', - sources: [fakeSourceFetcher('crypto', ['67234.10'])], - poster: fakeDiscordPoster(), - formatter: createLocalTemplateFormatter(), - embedder: createLocalEmbedder(), - runId: 'r2', - now: () => 2000, - }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(0); const reopened = openDatabase(ctx.dbPath); - const cryptoRow = reopened - .prepare(`SELECT nearest_match_id FROM agent_notifications WHERE source = 'crypto'`) - .get() as { nearest_match_id: number | null }; - expect(cryptoRow.nearest_match_id).toBeNull(); + const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; + expect(run.error).toMatch(/Discord 500/); + const notifications = reopened.prepare(`SELECT * FROM agent_notifications`).all(); + expect(notifications).toHaveLength(0); + const embCount = (reopened.prepare(`SELECT COUNT(*) AS c FROM agent_embeddings`).get() as { c: number }).c; + expect(embCount).toBe(0); reopened.close(); ctx.cleanup(); }); - it('embedding failure is isolated: notification still posts, error recorded, nearest_match stays null', async () => { - const poster = fakeDiscordPoster(); - const throwingEmbedder = { - async embed(): Promise { - throw new Error('Titan throttled'); - }, - }; - - const result = await runFetch({ + it('conditional write 412: no upload, previous version untouched, run marked error', async () => { + // First run publishes v1. + await runFetch({ dbPath: ctx.dbPath, store: ctx.store, storeKey: 'memory.db', sources: [fakeSourceFetcher('weather', ['72F'])], - poster, + poster: fakeDiscordPoster(), formatter: createLocalTemplateFormatter(), - embedder: throwingEmbedder, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', runId: 'r1', now: () => 1000, }); + const v1 = await ctx.store.get('memory.db'); - expect(result.outcome).toBe('success'); - expect(poster.posted).toEqual(['Weather update: 72F']); // post still happens despite embed failure + // Simulate a concurrent writer: append a byte so the next run's captured etag is stale. + await ctx.store.put('memory.db', Buffer.concat([v1!.body, Buffer.from('x')]), v1!.etag); - const reopened = openDatabase(ctx.dbPath); - const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; - expect(run.error).toMatch(/Titan throttled/); + const staleStore = { + ...ctx.store, + async get() { + return v1; + }, + }; - const notification = reopened - .prepare(`SELECT nearest_match_id FROM agent_notifications`) - .get() as { nearest_match_id: number | null }; - expect(notification.nearest_match_id).toBeNull(); + await expect( + runFetch({ + dbPath: ctx.dbPath, + store: staleStore, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['73F'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r2', + now: () => 2000, + }), + ).rejects.toThrow(/PreconditionFailed/); + + const current = await ctx.store.get('memory.db'); + expect(current?.body.length).toBe(v1!.body.length + 1); + + const reopened = openDatabase(ctx.dbPath); + const runRow = reopened.prepare(`SELECT outcome, error FROM agent_runs WHERE run_id = 'r2'`).get() as { outcome: string; error: string }; + expect(runRow.outcome).toBe('error'); + expect(runRow.error).toMatch(/PreconditionFailed/); reopened.close(); ctx.cleanup(); }); From ebe652e01f1d910901ff7d0870cdfab20331046b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:20:58 -0400 Subject: [PATCH 15/26] feat(fetch): thread weatherLocation through localFetch and handler --- src/handler.ts | 1 + src/localFetch.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/handler.ts b/src/handler.ts index f7cc45f..c225604 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -185,6 +185,7 @@ export async function runHandler( poster: createFetchDiscordPoster(config.discordWebhookUrl), formatter, embedder, + weatherLocation: config.weatherLocation, }); return { statusCode: 200, body: JSON.stringify(result) }; diff --git a/src/localFetch.ts b/src/localFetch.ts index 55ecb4b..97ea863 100644 --- a/src/localFetch.ts +++ b/src/localFetch.ts @@ -18,6 +18,7 @@ async function main(): Promise { poster: createFetchDiscordPoster(config.discordWebhookUrl), formatter: createLocalTemplateFormatter(), embedder: createLocalEmbedder(), + weatherLocation: config.weatherLocation, }); console.log(JSON.stringify(result, null, 2)); From 358ac75d04f34ded12812ac7fa858b5a4405a48c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:21:09 -0400 Subject: [PATCH 16/26] test(handler): assert one Converse call per tick with two sources --- tests/handler.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/handler.test.ts b/tests/handler.test.ts index c1ffa65..3a8b5b5 100644 --- a/tests/handler.test.ts +++ b/tests/handler.test.ts @@ -226,5 +226,36 @@ describe('runHandler', () => { ); expect(result.statusCode).toBe(200); }); + + it('makes exactly one Converse call per tick even with two sources configured', async () => { + s3.on(GetObjectCommand).rejects({ name: 'NoSuchKey' }); // bootstrap + s3.on(PutObjectCommand).resolves({ ETag: '"v1"' }); + bedrock.on(ConverseCommand).resolves({ + output: { message: { role: 'assistant', content: [{ text: 'A short friendly comment.\n\nA haiku here.' }] } }, + stopReason: 'end_turn', + }); + + const env = { + DISCORD_WEBHOOK_URL: 'https://discord.example/webhook', + SNAPSHOT_BUCKET: 'test-bucket', + DB_PATH: join(dir, 'memory.db'), + SOURCES: '["weather", "crypto"]', + }; + + const result = await runHandler( + { op: 'fetch' }, + env, + { s3Client: s3 as unknown as S3Client, bedrockClient: bedrock as unknown as BedrockRuntimeClient }, + { + weather: async () => '72F', + crypto: async () => '67234.10', + }, + ); + + expect(result.statusCode).toBe(200); + // The combined-message reformulation means exactly one formatter call per tick, + // regardless of source count — the per-source loop is gone. + expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(1); + }); }); }); From 38ce6a095b90a0cddbbbd0a690e6804e1b615e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:21:22 -0400 Subject: [PATCH 17/26] feat(infra): 5-min schedule, LoopRuleName output, 60s lambda timeout --- infra/stack.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/infra/stack.ts b/infra/stack.ts index 984a922..88f8bb6 100644 --- a/infra/stack.ts +++ b/infra/stack.ts @@ -84,7 +84,7 @@ class AgentStack extends cdk.Stack { }), architecture: lambda.Architecture.ARM_64, memorySize: 512, - timeout: cdk.Duration.seconds(30), + timeout: cdk.Duration.seconds(60), // Single-writer invariant (spec §2): without this, two overlapping `fetch` // invocations could both hydrate the same version and silently overwrite each // other's writes. Reader-overridable via RESERVED_CONCURRENCY env at synth time. @@ -124,9 +124,9 @@ class AgentStack extends cdk.Stack { // Constant JSON input, not a transformed event payload (spec §2): the handler reads // event.op directly without unwrapping EventBridge's own envelope shape. - new events.Rule(this, 'FetchSchedule', { + const fetchSchedule = new events.Rule(this, 'FetchSchedule', { enabled: true, - schedule: events.Schedule.rate(cdk.Duration.days(1)), + schedule: events.Schedule.rate(cdk.Duration.minutes(5)), targets: [ new targets.LambdaFunction(agentFunction, { event: events.RuleTargetInput.fromObject({ op: 'fetch' }), @@ -143,6 +143,7 @@ class AgentStack extends cdk.Stack { new cdk.CfnOutput(this, 'SnapshotBucketName', { value: bucket.bucketName }); new cdk.CfnOutput(this, 'AgentFunctionName', { value: agentFunction.functionName }); new cdk.CfnOutput(this, 'AgentFunctionUrl', { value: functionUrl.url }); + new cdk.CfnOutput(this, 'LoopRuleName', { value: fetchSchedule.ruleName }); } } From b84a76e9468e969f2b9a8e79f959ad2100568d40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:21:59 -0400 Subject: [PATCH 18/26] feat(scripts): loop-start.sh and loop-stop.sh toggle EventBridge rule --- scripts/loop-start.sh | 39 +++++++++++++++++++++++++++++++++++++++ scripts/loop-stop.sh | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100755 scripts/loop-start.sh create mode 100755 scripts/loop-stop.sh diff --git a/scripts/loop-start.sh b/scripts/loop-start.sh new file mode 100755 index 0000000..dc52878 --- /dev/null +++ b/scripts/loop-start.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROFILE="${AWS_PROFILE:-default}" +REGION="${AWS_REGION:-us-east-1}" +STACK_NAME="SqliteS3AgentTutorial" + +echo "=== Fetching loop rule name ===" +RULE_NAME=$(aws cloudformation describe-stacks \ + --profile "$PROFILE" \ + --region "$REGION" \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='LoopRuleName'].OutputValue" \ + --output text) + +if [ -z "$RULE_NAME" ] || [ "$RULE_NAME" = "None" ]; then + echo "FAIL: stack $STACK_NAME has no LoopRuleName output — is the loop feature already deployed?" >&2 + exit 1 +fi + +echo "Rule: $RULE_NAME" + +echo "" +echo "=== Enabling loop rule ===" +aws events enable-rule \ + --name "$RULE_NAME" \ + --profile "$PROFILE" \ + --region "$REGION" + +echo "" +echo "=== Confirming rule state ===" +STATE=$(aws events describe-rule \ + --name "$RULE_NAME" \ + --query State \ + --output text \ + --profile "$PROFILE" \ + --region "$REGION") + +echo "Rule $RULE_NAME is now: $STATE" diff --git a/scripts/loop-stop.sh b/scripts/loop-stop.sh new file mode 100755 index 0000000..1bc105a --- /dev/null +++ b/scripts/loop-stop.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROFILE="${AWS_PROFILE:-default}" +REGION="${AWS_REGION:-us-east-1}" +STACK_NAME="SqliteS3AgentTutorial" + +echo "=== Fetching loop rule name ===" +RULE_NAME=$(aws cloudformation describe-stacks \ + --profile "$PROFILE" \ + --region "$REGION" \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='LoopRuleName'].OutputValue" \ + --output text) + +if [ -z "$RULE_NAME" ] || [ "$RULE_NAME" = "None" ]; then + echo "FAIL: stack $STACK_NAME has no LoopRuleName output — is the loop feature already deployed?" >&2 + exit 1 +fi + +echo "Rule: $RULE_NAME" + +echo "" +echo "=== Disabling loop rule ===" +aws events disable-rule \ + --name "$RULE_NAME" \ + --profile "$PROFILE" \ + --region "$REGION" + +echo "" +echo "=== Confirming rule state ===" +STATE=$(aws events describe-rule \ + --name "$RULE_NAME" \ + --query State \ + --output text \ + --profile "$PROFILE" \ + --region "$REGION") + +echo "Rule $RULE_NAME is now: $STATE" +echo "" +echo "Note: running 'npm run deploy' after this script re-enables the rule, since the" +echo "CDK stack declares it 'enabled: true'. Re-run this script after any redeploy if" +echo "you want the loop to stay off." From aa78838aa9af37eb22ad494b27d145d565a25321 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:22:09 -0400 Subject: [PATCH 19/26] chore(package): expose loop-start and loop-stop npm scripts --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index fbc6518..5ad4942 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "local-fetch": "tsx src/localFetch.ts", "cdk": "cdk", "deploy": "bash scripts/deploy.sh", - "smoke": "bash scripts/smoke.sh" + "smoke": "bash scripts/smoke.sh", + "loop-start": "bash scripts/loop-start.sh", + "loop-stop": "bash scripts/loop-stop.sh" }, "dependencies": { "better-sqlite3": "^13.0.1", From d02f4256d5adef2e6d5228f14598d1d54528e104 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:22:12 -0400 Subject: [PATCH 20/26] docs(readme): add Loop mode subsection with start/stop scripts --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 28c5950..3b86352 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,32 @@ manual *Bedrock → Model access* console flow is no longer the gate for this mo [docs/02-rehydration.md](docs/02-rehydration.md#bedrock-setup) for what else is required and what breaks if you skip it. +## Loop mode + +For local testing and quick iteration, the agent can run a 5-minute loop instead of +the once-daily schedule. After the same `npm run deploy` as for the daily schedule, +toggle the loop on and off directly from your shell — no Lambda invocation, no token, +no extra IAM grants: + +```bash +npm run loop-start # calls `aws events enable-rule` on the deployed rule +npm run loop-stop # calls `aws events disable-rule` — no further ticks +``` + +While the loop is running, each tick posts one combined Discord message: a short +friendly comment drawn from today's date, weather, and crypto price, ending with a +haiku. If a past message in the corpus is close enough, the LLM's pre-suffix output +is mechanically appended with a `Reminds me of: ` line. Both scripts +read the rule name from the `LoopRuleName` stack output and call the EventBridge API +directly using the same AWS CLI credentials the smoke script already requires. + +**Stop the loop when you're done** — `loop-stop.sh` disables the EventBridge rule so +no further invocations occur and the recurring AWS cost stops. Note: running +`npm run deploy` after `loop-stop.sh` re-enables the rule, since the CDK stack +declares it `enabled: true` — re-run `loop-stop.sh` after any redeploy if you want +the loop to stay off. See [docs/07-budget-protection.md](docs/07-budget-protection.md) +for the per-day Bedrock call rate at 5-min cadence. + ## Triggering a fetch on demand The daily `fetch` run is normally EventBridge's job, but you can also trigger one over From 7294005dec8296787153c38de46bea39664af328 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:22:14 -0400 Subject: [PATCH 21/26] docs(budget): note 5-min loop Bedrock-call rate and row growth --- docs/07-budget-protection.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/07-budget-protection.md b/docs/07-budget-protection.md index 173316c..b5c1e70 100644 --- a/docs/07-budget-protection.md +++ b/docs/07-budget-protection.md @@ -21,6 +21,14 @@ often than intended. - **`SOURCES` or `BEDROCK_MAX_OUTPUT_TOKENS` misconfiguration.** More sources checked per run, or a much larger output token cap, both scale cost linearly and both are one environment variable away from the defaults. +- **Loop mode running unattended.** Switching the 5-minute loop on (`npm run loop-start`) + drives ~576 Bedrock calls per day (1 Converse + 1 Titan per tick × 288 ticks/day) and + grows both `agent_notifications` and `agent_embeddings` by ~576 rows each per day + (~1,152 rows/day combined). At default model pricing this is roughly $0.02–$0.04/day, + but a loop left running for a weekend amplifies the spend noticeably. `npm run + loop-stop` disables the EventBridge rule so no further ticks fire — re-run it after + any `npm run deploy` that re-enables the rule (see the redeploy caveat in the + README's Loop mode section). None of these are bugs this codebase can prevent by construction — they're operator error, and the right backstop for operator error is a spending alarm, not more code. From 0646b7c9eeef97f2a123719b8acfa04aec0d5d14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:26:24 -0400 Subject: [PATCH 22/26] docs(rehydration): note Lambda /tmp storage ceiling at loop cadence --- docs/02-rehydration.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/02-rehydration.md b/docs/02-rehydration.md index f7809cc..36d08a5 100644 --- a/docs/02-rehydration.md +++ b/docs/02-rehydration.md @@ -63,6 +63,31 @@ happens if you just overwrite `/tmp/memory.db` without closing first — the han cache goes stale silently. Queries keep succeeding; they just return wrong answers. Closing first is what prevents that. +## 4. `/tmp` storage ceiling + +The hydration pattern depends on a local file at `/tmp/memory.db`, which lives on +Lambda's ephemeral local storage — not on S3 and not on any volume that persists across +invocations. That storage has a hard ceiling: + +- **Default:** 512 MB. `/tmp` is provisioned at this size unless you override it. +- **AWS maximum:** 10,240 MB (10 GB), set via the `ephemeralStorage` prop on + `aws-cdk-lib/aws-lambda`'s `DockerImageFunction`. `infra/stack.ts` does not override + the default, so a fresh deploy runs at 512 MB. + +A snapshot row plus its embedding is roughly 4 KB on disk. 512 MB holds about 131,000 +ticks' worth of rows — enough that the file still fits in `/tmp` after about 450 days +of running the 5-minute loop (`npm run loop-start`, 288 ticks/day — see the README's Loop +mode section). Past that, `s3.GetObject` fails with `No space left on device` on the +next hydrate and the writer publishes nothing until a redeploy resurfaces a fresh +container with an empty `/tmp`. + +If you intend to leave the loop running unattended for longer than that, set +`ephemeralStorage: Size.gibibytes(10)` on `agentFunction` in `infra/stack.ts` and +redeploy. At the AWS cap the same math gives roughly 2.6 million ticks of headroom — +about 25 years at 5-minute cadence. The RAG corpus (`agent_notifications` + +`agent_embeddings`) is the dominant growth term; status reads and `agent_runs` rows are +small by comparison. + ## Bedrock setup Before the first `fetch` invocation can succeed, the deploying account in `us-east-1` needs From 2e0dcf7e6c0e15a1cdc3529102a3864fafb8ba92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:27:04 -0400 Subject: [PATCH 23/26] docs(spec): land loop-mode + poetic-closing plan and implemented spec Spec: status Approved -> Implemented; loop-start/stop simplified from token-gated Lambda ops to pure AWS CLI (aws events enable-rule / disable-rule), matching what shipped in PR #7. Plan: implementation plan that drove PR #7 (14 tasks, TDD throughout). --- .../plans/2026-08-09-loop-mode-poetry.md | 2465 +++++++++++++++++ .../2026-08-09-loop-mode-poetry-design.md | 219 +- 2 files changed, 2588 insertions(+), 96 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-09-loop-mode-poetry.md diff --git a/docs/superpowers/plans/2026-08-09-loop-mode-poetry.md b/docs/superpowers/plans/2026-08-09-loop-mode-poetry.md new file mode 100644 index 0000000..405e3b3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-loop-mode-poetry.md @@ -0,0 +1,2465 @@ +# Loop Mode + Poetic Closing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the once-daily EventBridge schedule with a 5-minute loop, write one combined message per tick (date + location + readings + haiku + mechanical "Reminds me of" suffix from a global RAG match), and add `loop-start.sh` / `loop-stop.sh` scripts that toggle the EventBridge rule directly via `aws events`. + +**Architecture:** The writer (`runFetch`) is restructured from a per-source format+post loop into a single combined-message flow: fetch all sources, format once with a clean `LoopContext` (no RAG fields), embed the LLM's pre-suffix output once, run a global KNN lookup against post-migration notifications, mechanically append `\n\nReminds me of: ` if a match exists, post once, write per-source `agent_notifications` rows with the same combined message, and reuse the pre-vector to insert embeddings (one Titan call per tick, not per source). The `base_message` column on `agent_notifications` stores the LLM's pre-suffix text and is the corpus/query key — `formatted_message` keeps the full posted text. Script-level control (`aws events enable-rule` / `disable-rule`) reads the rule name from a new `LoopRuleName` stack output. + +**Tech Stack:** TypeScript / Node 24 / ESM, vitest, `aws-sdk-client-mock`, AWS CDK, AWS CLI (EventBridge). + +**Design doc:** [docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md](../specs/2026-08-09-loop-mode-poetry-design.md) + +--- + +## File Structure + +**Modified:** +- `src/agent/fetch.ts` — restructured to combined-message flow; no dedup; new `weatherLocation` param; `base_message` writes. +- `src/format/types.ts` — adds `LoopContext` / `LoopReading`; deletes `SimilarPastResult`; `MessageFormatter.format(source, value, similarPast)` → `format(ctx: LoopContext)`. +- `src/format/bedrock.ts` — new haiku-focused system prompt; new `format(ctx)` signature; no RAG fields in the prompt. +- `src/format/local.ts` — new `format(ctx)` signature; deletes the `LABELS` map; emits a `"{date} — {source}: {value}, ..."` shape. +- `src/rag/similarity.ts` — `findNearestMatch` loses its `source` param; adds `WHERE n.base_message IS NOT NULL`; returns `baseMessage` instead of `formattedMessage`; `KNN_CANDIDATES` doc comment updated to the new cadence math. +- `src/db/bootstrap.ts` — adds the `base_message` column migration (PRAGMA-guarded, mirroring the existing nearest-match columns). +- `src/localFetch.ts` — passes `weatherLocation` through to `runFetch`. +- `src/handler.ts` — passes `weatherLocation` through to `runFetch`. +- `infra/stack.ts` — schedule `5 minutes` (was `1 day`); new `LoopRuleName` `CfnOutput`; Lambda timeout `60s` (was `30s`). +- `tests/fetch.test.ts` — rewritten for combined-message flow; new test cases from spec §7. +- `tests/similarity.test.ts` — rewritten for global KNN + `baseMessage` return shape; new tests for null `base_message` exclusion and the no-age-floor decision. +- `tests/format.test.ts` — updated for new `format(ctx)` signature. +- `tests/bedrock.test.ts` — updated for new `format(ctx)` signature, new system prompt, and the removal of the "closest past reading" prompt field. +- `tests/handler.test.ts` — updated to assert exactly one Converse call per tick. +- `package.json` — adds `loop-start` and `loop-stop` npm scripts. +- `README.md` — adds a "Loop mode" subsection documenting the scripts and the redeploy-re-enables-the-loop gotcha. +- `docs/07-budget-protection.md` — adds a paragraph about the 5-min loop's Bedrock-call rate and `agent_notifications` / `agent_embeddings` growth. + +**Created:** +- `scripts/loop-start.sh` — calls `aws events enable-rule` after reading `LoopRuleName` from stack outputs. +- `scripts/loop-stop.sh` — calls `aws events disable-rule` after reading `LoopRuleName` from stack outputs. + +--- + +## Task 1: Add `base_message` column migration to bootstrap + +**Files:** +- Modify: `src/db/bootstrap.ts` +- Test: `tests/db.test.ts` + +- [ ] **Step 1: Write a failing test for the new column** + +Append the following test to `tests/db.test.ts` (read the file first to find the right `describe` block — the existing tests in that file cover the `addNearestMatchColumnsIfMissing` paths; add the new test inside the same `describe` so it shares the `setup()` helper): + +```typescript + it('adds base_message to agent_notifications when missing, and is idempotent on re-run', () => { + const { db } = setup(); + + // After bootstrap, base_message column exists and is nullable. + const cols = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string; notnull: number }>; + const base = cols.find((c) => c.name === 'base_message'); + expect(base).toBeDefined(); + expect(base?.notnull).toBe(0); + + // Inserting a row with base_message = null is allowed (legacy rows post-migration). + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + db.prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at, base_message) + VALUES ('weather', 'v', 'msg', 1000, NULL)`, + ).run(); + + // Re-running bootstrap must not throw and must not alter the column. + expect(() => bootstrap(db)).not.toThrow(); + const colsAfter = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; + expect(colsAfter.filter((c) => c.name === 'base_message')).toHaveLength(1); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test -- tests/db.test.ts` +Expected: FAIL — `base_message` column not found in `agent_notifications` (the `cols.find(...)` returns `undefined`). + +- [ ] **Step 3: Implement the migration** + +Replace `src/db/bootstrap.ts` with the following: + +```typescript +import type Database from 'better-sqlite3'; +import { AGENT_DDL } from './schema.js'; + +/** Creates the three agent tables plus `agent_embeddings`. Idempotent — safe to call on + * every writer invocation. */ +export function bootstrap(db: Database.Database): void { + db.exec(AGENT_DDL); + addMissingColumns(db); +} + +/** + * SQLite has no `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so idempotency is + * implemented by checking `PRAGMA table_info` first. These columns record the + * past-notification relationship at the time this notification was posted: + * + * - `nearest_match_id` / `nearest_match_distance` — RAG design spec §3.2 + * - `base_message` — loop-mode + poetic-closing design spec §3. Stores the LLM's + * pre-suffix output (the friendly comment + haiku). The RAG corpus embeds this + * column, and `findNearestMatch` returns it for the "Reminds me of" suffix — + * never the posted `formatted_message` — so the suffix cannot snowball. + * Nullable so legacy rows post-migration carry `NULL` and the LIKE exclusion + * in `findNearestMatch` keeps them out of match candidacy. + */ +function addMissingColumns(db: Database.Database): void { + const columns = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; + const names = new Set(columns.map((c) => c.name)); + + if (!names.has('nearest_match_id')) { + db.exec( + `ALTER TABLE agent_notifications ADD COLUMN nearest_match_id INTEGER REFERENCES agent_notifications(id)`, + ); + } + if (!names.has('nearest_match_distance')) { + db.exec(`ALTER TABLE agent_notifications ADD COLUMN nearest_match_distance REAL`); + } + if (!names.has('base_message')) { + db.exec(`ALTER TABLE agent_notifications ADD COLUMN base_message TEXT`); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -- tests/db.test.ts` +Expected: PASS — all cases, including the new one. + +- [ ] **Step 5: Commit** + +```bash +git add src/db/bootstrap.ts tests/db.test.ts +git commit -m "feat(schema): add base_message column for snowball-free RAG suffix" +``` + +--- + +## Task 2: Update `findNearestMatch` to global KNN returning `baseMessage` + +**Files:** +- Modify: `src/rag/similarity.ts` +- Test: `tests/similarity.test.ts` + +- [ ] **Step 1: Write a failing test for the new return shape and null-base_message filter** + +Replace `tests/similarity.test.ts` (currently 98 lines) with the following — keeping the same `setup()` / `insertNotification()` / `unitVector()` helpers but rewriting the `findNearestMatch` describe block for the new signature: + +```typescript +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type Database from 'better-sqlite3'; +import { bootstrap } from '../src/db/bootstrap.js'; +import { openDatabase } from '../src/db/open.js'; +import { findNearestMatch, insertEmbedding } from '../src/rag/similarity.js'; + +function setup() { + const dir = mkdtempSync(join(tmpdir(), 'agent-similarity-test-')); + const db = openDatabase(join(dir, 'memory.db')); + bootstrap(db); + return { dir, db }; +} + +function cleanup(dir: string, db: Database.Database) { + db.close(); + rmSync(dir, { recursive: true, force: true }); +} + +function insertNotification( + db: Database.Database, + source: string, + formattedMessage: string, + postedAt: number, + baseMessage: string | null = formattedMessage, +): number { + const result = db + .prepare( + `INSERT INTO agent_notifications + (source, value, formatted_message, posted_at, base_message) + VALUES (?, 'v', ?, ?, ?)`, + ) + .run(source, formattedMessage, postedAt, baseMessage); + return Number(result.lastInsertRowid); +} + +/** 256-dim vector with a 1 at `index` and 0 elsewhere — lets tests reason about cosine + * distance by construction instead of by coincidence. */ +function unitVector(index: number): number[] { + const vector = new Array(256).fill(0); + vector[index] = 1; + return vector; +} + +describe('findNearestMatch', () => { + it('returns null when there is no embedded history yet', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + expect(findNearestMatch(db, unitVector(0))).toBeNull(); + cleanup(dir, db); + }); + + it('returns the closest notification (global, no per-source filter) and exposes baseMessage', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + const closeId = insertNotification(db, 'weather', 'close message', 1000, 'close base'); + insertEmbedding(db, closeId, unitVector(0)); + + const farId = insertNotification(db, 'weather', 'far message', 2000, 'far base'); + insertEmbedding(db, farId, unitVector(1)); + + const match = findNearestMatch(db, unitVector(0)); + expect(match).not.toBeNull(); + expect(match?.notificationId).toBe(closeId); + expect(match?.baseMessage).toBe('close base'); + expect(match?.postedAt).toBe(1000); + expect(match?.distance).toBeLessThan(0.01); + cleanup(dir, db); + }); + + it('matches across sources (no per-source filter) — KNN is global', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); + + const cryptoId = insertNotification(db, 'crypto', 'crypto base', 1000, 'crypto base'); + insertEmbedding(db, cryptoId, unitVector(0)); + + const weatherId = insertNotification(db, 'weather', 'weather base', 2000, 'weather base'); + insertEmbedding(db, weatherId, unitVector(5)); + + const match = findNearestMatch(db, unitVector(0)); + expect(match?.notificationId).toBe(cryptoId); // crypto is closer than weather + expect(match?.baseMessage).toBe('crypto base'); + cleanup(dir, db); + }); + + it('excludes rows whose base_message is NULL (legacy rows post-migration)', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + // Three legacy rows with base_message = NULL, vectors spanning the search space. + // The closest vector candidate should be a CRYPTO row with NULL base_message — + // a non-null row elsewhere must still be returned if present. + const legacyCrypto = insertNotification(db, 'crypto', 'legacy crypto', 1000, null); + insertEmbedding(db, legacyCrypto, unitVector(0)); // closest to the query below + + const legacyWeather = insertNotification(db, 'weather', 'legacy weather', 1100, null); + insertEmbedding(db, legacyWeather, unitVector(1)); + + const valid = insertNotification(db, 'weather', 'valid posted', 900, 'valid base'); + insertEmbedding(db, valid, unitVector(10)); // far from unitVector(0) but non-null base_message + + const match = findNearestMatch(db, unitVector(0)); + expect(match?.notificationId).toBe(valid); + expect(match?.baseMessage).toBe('valid base'); + cleanup(dir, db); + }); + + it('returns null when every candidate has base_message = NULL', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); + + const legacy1 = insertNotification(db, 'weather', 'legacy', 1000, null); + insertEmbedding(db, legacy1, unitVector(0)); + + const legacy2 = insertNotification(db, 'crypto', 'legacy', 1100, null); + insertEmbedding(db, legacy2, unitVector(1)); + + expect(findNearestMatch(db, unitVector(0))).toBeNull(); + cleanup(dir, db); + }); + + it('matches a recent (few-minutes-old) past notification — no age floor', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + // Reference tick: 5 minutes ago. + const recent = insertNotification(db, 'weather', 'recent posted', 1000, 'recent base'); + insertEmbedding(db, recent, unitVector(0)); + + // Query "now" (no time gap engineered in): the function takes no timestamp, + // so any match from the corpus is valid — the no-age-floor decision is + // pinned by the absence of a timestamp filter, not by an explicit one. + const match = findNearestMatch(db, unitVector(0)); + expect(match?.notificationId).toBe(recent); + expect(match?.baseMessage).toBe('recent base'); + cleanup(dir, db); + }); +}); + +describe('insertEmbedding', () => { + it('stores a vector retrievable by a later findNearestMatch call', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + const id = insertNotification(db, 'weather', 'stored message', 1000, 'stored base'); + + expect(() => insertEmbedding(db, id, unitVector(3))).not.toThrow(); + + const match = findNearestMatch(db, unitVector(3)); + expect(match?.notificationId).toBe(id); + expect(match?.baseMessage).toBe('stored base'); + cleanup(dir, db); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test -- tests/similarity.test.ts` +Expected: FAIL — `findNearestMatch` still takes a `source` parameter and returns `formattedMessage`; the new tests reference the new signature and the new field name. + +- [ ] **Step 3: Update `src/rag/similarity.ts`** + +Replace `src/rag/similarity.ts` (currently 73 lines) with the following: + +```typescript +import type Database from 'better-sqlite3'; + +/** A match found by `findNearestMatch` — the past tick's pre-suffix output (the LLM's + * clean message, never its `formatted_message`). The writer uses `baseMessage` to + * build the "Reminds me of" suffix; using `formattedMessage` instead would let the + * suffix grow recursively and eventually blow the Discord 2000-char limit (loop-mode + * + poetic-closing spec §1, §4.5). */ +export interface NearestMatch { + notificationId: number; + distance: number; + baseMessage: string; + postedAt: number; +} + +/** + * Fixed KNN scan size (loop-mode + poetic-closing spec §4.5). At 5-min cadence, 2 sources, + * 1 row per source per tick, 50 candidates is roughly 2 hours of wall-clock history — + * a generous window for the loop's intended short-lived test runs (5–10 minutes), so the + * ceiling is unlikely to bind in practice. Past this ceiling, `findNearestMatch` can miss + * the true nearest neighbor if it isn't among the 50 closest across `agent_embeddings`. + * Same pattern as `RECENT_NOTIFICATIONS_LIMIT` in `src/agent/status.ts`. + */ +const KNN_CANDIDATES = 50; + +interface CandidateRow { + notificationId: number; + baseMessage: string; + postedAt: number; + distance: number; +} + +/** + * Finds the closest past notification to `queryVector` across all sources (global KNN, + * no per-source filter), or `null` if no eligible history exists. The `agent_embeddings` + * table is shared across sources (RAG design spec §3.1). + * + * `WHERE n.base_message IS NOT NULL` is applied as a post-filter on the top-`k` + * candidates — `sqlite-vec`'s `k` parameter operates on the raw vector scan, so this + * filter runs after the KNN. It is required: without it, legacy rows (post-migration + * `base_message = NULL`) would surface as `agent_embeddings` candidates whose joined + * `base_message` is null, and the writer would post the literal string `"null"` into + * the "Reminds me of" suffix. + * + * Step-ordering note: this scan runs *before* the current tick's `insertEmbedding` (which + * happens after the Discord post in `runFetch`), so the corpus at query time contains + * only notifications already posted by prior ticks. The current tick's own message + * cannot be its own match — no explicit age floor is needed to enforce that. + */ +export function findNearestMatch(db: Database.Database, queryVector: number[]): NearestMatch | null { + const rows = db + .prepare( + `SELECT n.id AS notificationId, n.base_message AS baseMessage, + n.posted_at AS postedAt, e.distance AS distance FROM agent_embeddings e + JOIN agent_notifications n ON n.id = e.notification_id + WHERE e.embedding MATCH ? AND k = ? + AND n.base_message IS NOT NULL + ORDER BY e.distance`, + ) + .all(JSON.stringify(queryVector), KNN_CANDIDATES) as CandidateRow[]; + + const match = rows[0]; + if (match === undefined) return null; + + return { + notificationId: match.notificationId, + distance: match.distance, + baseMessage: match.baseMessage, + postedAt: match.postedAt, + }; +} + +/** Stores `vector` for `notificationId`, making it a future `findNearestMatch` + * candidate. Called once per posted notification (RAG design spec §3.1). + * + * `notificationId` must be bound as a `BigInt`: binding it as a plain JS number trips + * `vec0`'s "Only integers are allowed for primary key values" check in `better-sqlite3` + * (verified against installed sqlite-vec v0.1.9 + better-sqlite3 v13 — the same literal + * value works fine via `db.exec` with an inlined integer, so this is specific to bound + * parameters on this virtual table, not a general integer-vs-float issue). */ +export function insertEmbedding(db: Database.Database, notificationId: number, vector: number[]): void { + db.prepare(`INSERT INTO agent_embeddings (notification_id, embedding) VALUES (?, vec_f32(?))`).run( + BigInt(notificationId), + JSON.stringify(vector), + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -- tests/similarity.test.ts` +Expected: PASS — all cases. + +- [ ] **Step 5: Run typecheck to catch any leftover callers** + +Run: `npm run typecheck` +Expected: FAIL — `findNearestMatch` callers in `src/agent/fetch.ts` still pass a `source` argument and access `formattedMessage`. This is expected; Task 5 fixes `fetch.ts`. Note the failures; we'll continue. + +- [ ] **Step 6: Commit** + +```bash +git add src/rag/similarity.ts tests/similarity.test.ts +git commit -m "feat(rag): global KNN returns baseMessage, filter null base_message" +``` + +--- + +## Task 3: Add `LoopContext` types and the new `format()` signature + +**Files:** +- Modify: `src/format/types.ts` + +- [ ] **Step 1: Replace `src/format/types.ts`** + +Replace `src/format/types.ts` (currently 22 lines) with the following: + +```typescript +import type { SourceName } from '../db/schema.js'; + +export type { SourceName }; + +/** One entry per source that fetched successfully this tick — source-agnostic, so a + * third source added per `docs/04-extending.md` shows up in the prompt automatically + * (loop-mode + poetic-closing spec §4.2). A failed source is absent from the array + * rather than represented as `''` — the LLM sees only sources it has + * real data for. */ +export interface LoopReading { + source: SourceName; + value: string; +} + +/** The full set of inputs the formatter sees per tick. `date` and `location` are simple + * ambient context; `readings` carries the structured data the LLM should weave into + * its friendly comment and closing haiku. RAG is intentionally *not* in this shape: + * the LLM is not told about the closest past reading. The "Reminds me of" suffix is + * appended mechanically in the writer after the format call returns. */ +export interface LoopContext { + date: string; // ISO date, e.g. "2026-08-09" + location: string; // e.g. "NYC" + readings: LoopReading[]; // one entry per source that succeeded this tick +} + +/** Turns a `LoopContext` into a friendly Discord message. `LocalTemplateFormatter` and + * `BedrockFormatter` implement the same interface, so the writer's hot path does not + * change between local and deployed. The LLM is given `date`, `location`, and + * `readings`; the writer mechanically appends the "Reminds me of" suffix to the + * formatter's output after the RAG lookup, so the formatter never sees the closest + * past reading. */ +export interface MessageFormatter { + format(ctx: LoopContext): Promise; +} +``` + +- [ ] **Step 2: Run typecheck to confirm the new shape compiles** + +Run: `npm run typecheck` +Expected: FAIL — `LoopContext` is now required by `MessageFormatter.format`, but `LocalTemplateFormatter` and `BedrockFormatter` still have the old `(source, value, similarPast)` signature. This is expected; Tasks 4 and 5 fix the call sites and the formatter implementations. + +- [ ] **Step 3: Commit** + +```bash +git add src/format/types.ts +git commit -m "refactor(format): introduce LoopContext, remove SimilarPastResult" +``` + +--- + +## Task 4: Update `LocalTemplateFormatter` to the new signature + +**Files:** +- Modify: `src/format/local.ts` +- Test: `tests/format.test.ts` + +- [ ] **Step 1: Update `tests/format.test.ts` for the new signature** + +Replace `tests/format.test.ts` (currently 24 lines) with the following: + +```typescript +// tests/format.test.ts +import { describe, expect, it } from 'vitest'; +import { createLocalTemplateFormatter } from '../src/format/local.js'; + +const ctx = (readings: Array<{ source: 'weather' | 'crypto'; value: string }>, date = '2026-08-09', location = 'NYC') => ({ + date, + location, + readings, +}); + +describe('LocalTemplateFormatter', () => { + it('formats a single weather reading with date and location', async () => { + const formatter = createLocalTemplateFormatter(); + const message = await formatter.format(ctx([{ source: 'weather', value: '72F' }])); + expect(message).toBe('2026-08-09 — NYC — weather: 72F'); + }); + + it('formats a single crypto reading with date and location', async () => { + const formatter = createLocalTemplateFormatter(); + const message = await formatter.format(ctx([{ source: 'crypto', value: '67234.10' }])); + expect(message).toBe('2026-08-09 — NYC — crypto: 67234.10'); + }); + + it('joins multiple readings with comma separators', async () => { + const formatter = createLocalTemplateFormatter(); + const message = await formatter.format( + ctx([ + { source: 'weather', value: '72F' }, + { source: 'crypto', value: '67234.10' }, + ]), + ); + expect(message).toBe('2026-08-09 — NYC — weather: 72F, crypto: 67234.10'); + }); + + it('produces the same output for the same input across calls', async () => { + const formatter = createLocalTemplateFormatter(); + const first = await formatter.format(ctx([{ source: 'weather', value: '72F' }])); + const second = await formatter.format(ctx([{ source: 'weather', value: '72F' }])); + expect(first).toBe(second); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test -- tests/format.test.ts` +Expected: FAIL — `createLocalTemplateFormatter().format(ctx)` calls the old signature. + +- [ ] **Step 3: Update `src/format/local.ts`** + +Replace `src/format/local.ts` (currently 13 lines) with the following: + +```typescript +import type { LoopContext, MessageFormatter } from './types.js'; + +/** Deterministic, no-AWS `MessageFormatter` for local runs (spec §9). Emits a + * `"{date} — {location} — {source}: {value}, ..."` shape — one segment per reading + * in input order. Source-agnostic: a third source added per `docs/04-extending.md` + * shows up in the output automatically. Not expected to generate a haiku — it's a + * test-only stub. Never used in the deployed Lambda — `BedrockFormatter` is the + * default there (`src/handler.ts`). */ +export function createLocalTemplateFormatter(): MessageFormatter { + return { + async format(ctx: LoopContext): Promise { + const segments = ctx.readings.map((r) => `${r.source}: ${r.value}`).join(', '); + return `${ctx.date} — ${ctx.location} — ${segments}`; + }, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -- tests/format.test.ts` +Expected: PASS — all four cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/format/local.ts tests/format.test.ts +git commit -m "refactor(format): local template uses LoopContext, drops LABELS" +``` + +--- + +## Task 5: Update `BedrockFormatter` to the new signature and haiku prompt + +**Files:** +- Modify: `src/format/bedrock.ts` +- Test: `tests/bedrock.test.ts` + +- [ ] **Step 1: Update `tests/bedrock.test.ts` for the new signature and prompt** + +Replace `tests/bedrock.test.ts` (currently 177 lines) with the following. The "closest past reading" tests are removed (the LLM is no longer asked about RAG); the new shape is `(ctx: LoopContext)` and the system prompt is the haiku-instruction string from spec §4.3. + +```typescript +import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime'; +import { mockClient } from 'aws-sdk-client-mock'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createBedrockFormatter } from '../src/format/bedrock.js'; +import type { LoopContext } from '../src/format/types.js'; + +const bedrock = mockClient(BedrockRuntimeClient); + +function textResponse(text: string) { + return { + output: { message: { role: 'assistant' as const, content: [{ text }] } }, + stopReason: 'end_turn' as const, + }; +} + +function emptyResponse() { + return { + output: { message: { role: 'assistant' as const, content: [] as never[] } }, + stopReason: 'end_turn' as const, + }; +} + +const ctx: LoopContext = { + date: '2026-08-09', + location: 'NYC', + readings: [{ source: 'weather', value: '72F' }], +}; + +describe('createBedrockFormatter', () => { + beforeEach(() => bedrock.reset()); + afterEach(() => bedrock.reset()); + + const client = new BedrockRuntimeClient({ region: 'us-east-1' }); + + it('calls Converse with the configured model id and returns the response text', async () => { + bedrock.on(ConverseCommand).resolves(textResponse('A short friendly comment.\n\nA haiku here.')); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + const message = await formatter.format(ctx); + expect(message).toBe('A short friendly comment.\n\nA haiku here.'); + + const calls = bedrock.commandCalls(ConverseCommand); + expect(calls[0]?.args[0].input?.modelId).toBe('zai.glm-4.7-flash'); + }); + + it('prepends the family default inference-profile prefix (anthropic.claude → global.)', async () => { + // spec §12.3: anthropic.claude-* requires a `global.` (or `us.`) inference-profile + // prefix. Without it, Bedrock returns ResourceNotFoundException even when the base + // id is valid. The base id is configured; the prefix is supplied by the family. + bedrock.on(ConverseCommand).resolves(textResponse('from claude')); + + const formatter = createBedrockFormatter({ + client, + modelId: 'anthropic.claude-haiku-4-5-20251001-v1:0', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await formatter.format(ctx); + + const calls = bedrock.commandCalls(ConverseCommand); + expect(calls[0]?.args[0].input?.modelId).toBe( + 'global.anthropic.claude-haiku-4-5-20251001-v1:0', + ); + }); + + it('uses the haiku-instruction system prompt and includes the LoopContext fields in the user message', async () => { + bedrock.on(ConverseCommand).resolves(textResponse('ok')); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await formatter.format({ + date: '2026-08-09', + location: 'NYC', + readings: [ + { source: 'weather', value: '72F' }, + { source: 'crypto', value: '67234.10' }, + ], + }); + + const calls = bedrock.commandCalls(ConverseCommand); + const input = calls[0]?.args[0].input; + + // System prompt asks for a haiku — the LLM is never told about RAG. + const systemText = input?.system?.[0]?.text ?? ''; + expect(systemText).toMatch(/haiku/i); + expect(systemText).not.toMatch(/closest past reading/i); + + // User message carries date, location, and per-source readings. + const userText = input?.messages?.[0]?.content?.[0]?.text ?? ''; + expect(userText).toContain('2026-08-09'); + expect(userText).toContain('NYC'); + expect(userText).toContain('weather'); + expect(userText).toContain('72F'); + expect(userText).toContain('crypto'); + expect(userText).toContain('67234.10'); + }); + + it('throws a descriptive error on AccessDeniedException', async () => { + bedrock.on(ConverseCommand).rejects({ name: 'AccessDeniedException', message: 'denied' }); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await expect(formatter.format(ctx)).rejects.toThrow(/model access/i); + }); + + it('throws a descriptive error on ResourceNotFoundException naming the model id and region', async () => { + bedrock.on(ConverseCommand).rejects({ name: 'ResourceNotFoundException', message: 'not found' }); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await expect(formatter.format(ctx)).rejects.toThrow(/zai\.glm-4\.7-flash/); + await expect(formatter.format(ctx)).rejects.toThrow(/us-east-1/); + }); + + it('throws on a malformed response with no content, no retry', async () => { + bedrock.on(ConverseCommand).resolves(emptyResponse()); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await expect(formatter.format(ctx)).rejects.toThrow(/no text/i); + expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(1); + }); + + it('retries once on ThrottlingException, then succeeds', async () => { + bedrock + .on(ConverseCommand) + .rejectsOnce({ name: 'ThrottlingException', message: 'slow down' }) + .resolves(textResponse('formatted after retry')); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + const message = await formatter.format(ctx); + expect(message).toBe('formatted after retry'); + expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(2); + }); + + it('retries once on ThrottlingException, then throws if it fails again', async () => { + bedrock.on(ConverseCommand).rejects({ name: 'ThrottlingException', message: 'slow down' }); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await expect(formatter.format(ctx)).rejects.toThrow(/Throttl/); + expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test -- tests/bedrock.test.ts` +Expected: FAIL — `formatter.format(ctx)` is the new signature; current `format()` still takes `(source, value, similarPast?)`. + +- [ ] **Step 3: Update `src/format/bedrock.ts`** + +Replace `src/format/bedrock.ts` (currently 132 lines) with the following: + +```typescript +import { type BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime'; +import { resolveFamily } from './families.js'; +import type { LoopContext, MessageFormatter } from './types.js'; + +export interface BedrockFormatterOptions { + client: BedrockRuntimeClient; + modelId: string; + region: string; + maxOutputTokens: number; +} + +const RETRY_DELAY_MS = 500; + +/** + * Composes the wire id from the configured base id and the family's default prefix + * (spec §12.3). The base id alone is not always valid: `anthropic.claude-*` requires + * a `global.` (or `us.`) inference-profile prefix, and bare-form `amazon.nova-*` does + * not. `zai.*` accepts the bare form (empty prefix), so for the default model this is + * a no-op. `resolveFamily` is called again here (after `loadConfig` validates it at + * startup) so the formatter owns the prefix-composition step rather than requiring + * `loadConfig` to pre-compose. + */ +function composedModelId(baseModelId: string): string { + const family = resolveFamily(baseModelId); + const prefix = family.prefixes[0] ?? ''; + return `${prefix}${baseModelId}`; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * The LLM writes a short friendly comment and a closing haiku. The LLM is *not* told + * about RAG — the "Reminds me of" suffix is appended mechanically in the writer after + * the format call returns (loop-mode + poetic-closing spec §4.3). Numerology is + * deliberately out: the LLM produces cliché numerology platitudes, while a haiku + * gives the model real creative room and reads as varied across runs. + */ +const SYSTEM_PROMPT = + 'You write a short, friendly Discord message for a check-in bot that posts a ' + + 'combined snapshot of a few tracked values every few minutes. The user ' + + 'message below contains today\'s date, the location, and the current value ' + + 'of each tracked reading. Write a brief comment (one or two sentences) that ' + + 'draws on these inputs — vary your phrasing across runs; do not repeat the ' + + 'same template. End with a short haiku (three lines, 5-7-5 syllables) that ' + + 'weaves in the readings and the day\'s vibe. ' + + 'Reply with the message text only — no quotes, no preamble, no markdown.'; + +function buildUserPrompt(ctx: LoopContext): string { + const lines = [ + `Date: ${ctx.date}`, + `Location: ${ctx.location}`, + ...ctx.readings.map((r) => `${r.source}: ${r.value}`), + ]; + return lines.join('\n'); +} + +/** Maps a Bedrock exception to a message naming the fix (spec §6, §12.4). */ +function mapBedrockError(error: unknown, options: BedrockFormatterOptions): Error { + const { modelId, region } = options; + const where = `model "${modelId}", region ${region}`; + const name = error instanceof Error ? error.name : (error as { name?: string })?.name ?? 'UnknownError'; + const detail = error instanceof Error ? error.message : String((error as { message?: string })?.message ?? error); + + switch (name) { + case 'AccessDeniedException': + return new Error( + `Bedrock model access is not granted for ${where}. Enable the model in the ` + + `Bedrock console's Model access page for this account and region (spec §12.1). ` + + `Underlying error: ${detail}`, + { cause: error }, + ); + case 'ValidationException': + return new Error( + `Bedrock rejected the request for ${where}. This is almost always a model-family ` + + `mismatch (spec §12.3) — re-probe the model's accepted request shape before ` + + `changing src/format/families.ts. Underlying error: ${detail}`, + { cause: error }, + ); + case 'ResourceNotFoundException': + return new Error( + `Bedrock does not recognise the model id for ${where}. Check that bedrockModelId ` + + `is the base id with no inference-profile prefix — the prefix is supplied by the ` + + `family (spec §12.3). Underlying error: ${detail}`, + { cause: error }, + ); + default: + return new Error(`Bedrock call failed for ${where} with ${name}: ${detail}`, { cause: error }); + } +} + +function isThrottlingOr5xx(error: unknown): boolean { + const name = error instanceof Error ? error.name : (error as { name?: string })?.name; + return name === 'ThrottlingException' || name === 'InternalServerException' || name === 'ServiceUnavailableException'; +} + +/** + * `MessageFormatter` backed by Amazon Bedrock's Converse API (spec §2, §12). Default + * model is `zai.glm-4.7-flash`, resolved and validated by `src/config.ts` at load time. + * + * One retry on `ThrottlingException`/5xx with a fixed ~500ms backoff (spec §6); every + * other exception is not retried — access and validation failures are not transient. + */ +export function createBedrockFormatter(options: BedrockFormatterOptions): MessageFormatter { + async function attempt(ctx: LoopContext): Promise { + const response = await options.client.send( + new ConverseCommand({ + modelId: composedModelId(options.modelId), + system: [{ text: SYSTEM_PROMPT }], + messages: [{ role: 'user', content: [{ text: buildUserPrompt(ctx) }] }], + inferenceConfig: { maxTokens: options.maxOutputTokens }, + }), + ); + + const text = (response.output?.message?.content ?? []).map((block) => block.text ?? '').join(''); + if (text === '') { + throw new Error(`Bedrock returned no text (stopReason: ${response.stopReason ?? 'unknown'})`); + } + return text; + } + + return { + async format(ctx: LoopContext): Promise { + try { + return await attempt(ctx); + } catch (error: unknown) { + if (isThrottlingOr5xx(error)) { + await delay(RETRY_DELAY_MS); + try { + return await attempt(ctx); + } catch (retryError: unknown) { + throw mapBedrockError(retryError, options); + } + } + if (error instanceof Error && error.message.startsWith('Bedrock returned no text')) { + throw error; // malformed response — not retried, message is already descriptive + } + throw mapBedrockError(error, options); + } + }, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -- tests/bedrock.test.ts` +Expected: PASS — all eight cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/format/bedrock.ts tests/bedrock.test.ts +git commit -m "feat(format): Bedrock formatter uses LoopContext, system prompt asks for haiku" +``` + +--- + +## Task 6: Restructure `runFetch` to the combined-message, two-step RAG flow + +**Files:** +- Modify: `src/agent/fetch.ts` +- Test: `tests/fetch.test.ts` + +This is the biggest single change in the plan. The signature gains `weatherLocation: string`; the per-source loop is replaced with: fetch all → skip if all-failed → format once → embed LLM output → KNN → append suffix → post once → per-source inserts reusing the pre-vector. The test file is rewritten to match. + +- [ ] **Step 1: Replace `tests/fetch.test.ts`** + +Replace `tests/fetch.test.ts` (currently 366 lines) with the following. The new file uses an `IncrementClock` fake `now()` so the snowball test can drive 20 ticks and assert the RAG corpus is fresh each tick. + +```typescript +// tests/fetch.test.ts +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { bootstrap } from '../src/db/bootstrap.js'; +import { openDatabase } from '../src/db/open.js'; +import { createLocalStore } from '../src/store/local.js'; +import { createLocalTemplateFormatter } from '../src/format/local.js'; +import { createLocalEmbedder } from '../src/embed/local.js'; +import type { LoopContext } from '../src/format/types.js'; +import type { MessageFormatter } from '../src/format/types.js'; +import { runFetch } from '../src/agent/fetch.js'; +import { fakeSourceFetcher, throwingSourceFetcher } from './helpers/fakeSourceFetcher.js'; +import { fakeDiscordPoster } from './helpers/fakeDiscordPoster.js'; +import type { Embedder } from '../src/embed/titan.js'; + +function setup() { + const dir = mkdtempSync(join(tmpdir(), 'agent-fetch-test-')); + const dbPath = join(dir, 'memory.db'); + const storeDir = join(dir, 'store'); + return { + dbPath, + store: createLocalStore(storeDir), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +/** A `MessageFormatter` that records every `format()` call and returns a fixed + * ~150-char `preMessage`. Useful for asserting exact call counts and for the + * snowball regression test (every tick produces the same base message, so every + * tick's RAG lookup matches the previous tick). */ +function recordingFixedFormatter(preMessage = 'A short friendly comment. Today the weather is 72F and BTC is 67234.10. The vibe is calm, the city is bright, the work goes on.'): MessageFormatter & { calls: LoopContext[] } { + const calls: LoopContext[] = []; + return { + calls, + async format(ctx: LoopContext): Promise { + calls.push(ctx); + return preMessage; + }, + }; +} + +describe('runFetch', () => { + let ctx: ReturnType; + + beforeEach(() => { + ctx = setup(); + }); + + it('happy path with RAG history: one combined post, two notification rows, two embeddings, embed called once', async () => { + // Both ticks use the same recordingFixedFormatter so each tick's preMessage + // is identical and the second tick's RAG lookup is guaranteed to match the + // first tick's embeddings (same vector → cosine distance ≈ 0). The character- + // code-hash local embedder differentiates by string content, so two different + // preMessages would produce different vectors and the second tick could miss + // the first tick's embeddings. + const formatter = recordingFixedFormatter(); + const realEmbedder = createLocalEmbedder(); + let embedCalls = 0; + const wrappedEmbedder: Embedder = { + async embed(text: string) { + embedCalls++; + return realEmbedder.embed(text); + }, + }; + + // First tick seeds the corpus. + const first = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster: fakeDiscordPoster(), + formatter, + embedder: wrappedEmbedder, + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + expect(first.outcome).toBe('success'); + expect(first.notificationsSent).toBe(1); + + const embedCallsAfterFirst = embedCalls; + + // Second tick is the one we assert against. + const poster = fakeDiscordPoster(); + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [ + fakeSourceFetcher('weather', ['73F']), + fakeSourceFetcher('crypto', ['67500.00']), + ], + poster, + formatter, + embedder: wrappedEmbedder, + weatherLocation: 'Brooklyn', + runId: 'r2', + now: () => 2000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(1); + + // Formatter called once per tick — two calls total, the second tick's call is what + // we pin the LoopContext shape against. + expect(formatter.calls).toHaveLength(2); + const r2Ctx = formatter.calls[1]; + expect(r2Ctx?.date).toBe('1970-01-01'); // epoch date — this stub doesn't override `now` + expect(r2Ctx?.location).toBe('Brooklyn'); + expect(r2Ctx?.readings.map((r) => ({ source: r.source, value: r.value }))).toEqual([ + { source: 'weather', value: '73F' }, + { source: 'crypto', value: '67500.00' }, + ]); + + // Discord posted exactly once on the second tick; the message is the recorder's + // output with the suffix. + expect(poster.posted).toHaveLength(1); + expect(poster.posted[0]).toMatch(/^A short friendly comment\./); + expect(poster.posted[0]).toContain('Reminds me of:'); + + // The embed call runs once per tick (not per source) — one call on r1, one on r2. + expect(embedCalls - embedCallsAfterFirst).toBe(1); + + // DB has two agent_notifications rows per tick (one per source) with the same + // combined message and the same RAG match. + const reopened = openDatabase(ctx.dbPath); + const rows = reopened + .prepare( + `SELECT source, value, formatted_message, base_message, posted_at, + nearest_match_id, nearest_match_distance + FROM agent_notifications WHERE posted_at = 2000 ORDER BY source`, + ) + .all() as Array<{ + source: string; + value: string; + formatted_message: string; + base_message: string; + posted_at: number; + nearest_match_id: number | null; + nearest_match_distance: number | null; + }>; + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.formatted_message).toBe(poster.posted[0]); + expect(row.base_message).toBe( + 'A short friendly comment. Today the weather is 72F and BTC is 67234.10. The vibe is calm, the city is bright, the work goes on.', + ); + expect(row.nearest_match_id).not.toBeNull(); + expect(row.posted_at).toBe(2000); + } + + // Both notification rows share the same nearest_match_id (the global match). + expect(rows[0]?.nearest_match_id).toBe(rows[1]?.nearest_match_id); + + // Two embeddings for each tick (one per source), keyed on base_message. + const embCount = (reopened.prepare(`SELECT COUNT(*) AS c FROM agent_embeddings`).get() as { c: number }).c; + expect(embCount).toBe(4); // 2 from r1 + 2 from r2 + reopened.close(); + ctx.cleanup(); + }); + + it('first-tick path: no RAG history, posted message = preMessage (no suffix)', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(1); + expect(poster.posted).toHaveLength(1); + expect(poster.posted[0]).not.toContain('Reminds me of'); + + const reopened = openDatabase(ctx.dbPath); + const rows = reopened + .prepare(`SELECT formatted_message, base_message, nearest_match_id FROM agent_notifications`) + .all() as Array<{ formatted_message: string; base_message: string; nearest_match_id: number | null }>; + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.formatted_message).toBe(row.base_message); + expect(row.nearest_match_id).toBeNull(); + } + reopened.close(); + ctx.cleanup(); + }); + + it('brand-new source: agent_sources upsert runs before agent_notifications insert (no FK violation)', async () => { + // Fresh DB: no `agent_sources` rows exist yet for 'weather' or 'crypto'. The first + // tick on each is the brand-new path. The writer must upsert agent_sources before + // inserting agent_notifications because of the FK on agent_notifications.source. + const formatter = createLocalTemplateFormatter(); + const poster = fakeDiscordPoster(); + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + const reopened = openDatabase(ctx.dbPath); + const sources = reopened.prepare(`SELECT name FROM agent_sources ORDER BY name`).all() as Array<{ name: string }>; + expect(sources.map((s) => s.name).sort()).toEqual(['crypto', 'weather']); + const notifications = reopened.prepare(`SELECT source FROM agent_notifications`).all() as Array<{ source: string }>; + expect(notifications.map((n) => n.source).sort()).toEqual(['crypto', 'weather']); + reopened.close(); + ctx.cleanup(); + }); + + it('legacy-corpus RAG: skips null base_message rows even when they are the closest vectors', async () => { + // Seed with three notifications whose base_message is NULL (legacy post-migration). + const db = openDatabase(ctx.dbPath); + bootstrap(db); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); + + const vector = new Array(256).fill(0); + vector[0] = 1; + + const legacy1 = db + .prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at, base_message) + VALUES ('crypto', 'v', 'legacy crypto', 1000, NULL)`, + ) + .run(); + const insertEmbedding = (await import('../src/rag/similarity.js')).insertEmbedding; + insertEmbedding(db, Number(legacy1.lastInsertRowid), vector); // exact match for query + + db.prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at, base_message) + VALUES ('weather', 'v', 'legacy weather', 1100, NULL)`, + ).run(); + db.close(); + await ctx.store.put('memory.db', readFileSync(ctx.dbPath), null); + + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['73F'])], + poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 2000, + }); + + expect(result.outcome).toBe('success'); + // No valid match in the corpus → no suffix. + expect(poster.posted[0]).not.toContain('Reminds me of'); + expect(poster.posted[0]).not.toContain('null'); + ctx.cleanup(); + }); + + it('snowball regression: 20 consecutive ticks with the same fixed preMessage stay bounded', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + const embedder = createLocalEmbedder(); + + let now = 1000; + for (let i = 0; i < 20; i++) { + const tickStart = now; + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [ + fakeSourceFetcher('weather', ['72F']), + fakeSourceFetcher('crypto', ['67234.10']), + ], + poster, + formatter, + embedder, + weatherLocation: 'NYC', + runId: `r${i}`, + now: () => tickStart, + }); + now += 5 * 60 * 1000; // 5 minutes per tick + } + + expect(poster.posted).toHaveLength(20); + for (const message of poster.posted) { + expect(message.length).toBeLessThan(500); // well under Discord's 2000-char limit + // The suffix, if present, is built from a past base_message — never a past + // formatted_message — so the chain never grows. + expect(message).not.toMatch(/Reminds me of:.*Reminds me of:/); + } + ctx.cleanup(); + }); + + it('matches a recent past tick (no age floor)', async () => { + // Seed one tick. + const first = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + expect(first.notificationsSent).toBe(1); + + // Second tick a few "minutes" later — the past tick is fresh, but still a valid match. + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['73F']), fakeSourceFetcher('crypto', ['67500.00'])], + poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r2', + now: () => 1000 + 5 * 60 * 1000, // 5 minutes later + }); + + expect(poster.posted[0]).toContain('Reminds me of:'); + ctx.cleanup(); + }); + + it('runFetch never matches its own tick (lookup before insert)', async () => { + // The RAG lookup runs before this tick's agent_embeddings row is inserted — so + // the corpus at query time cannot contain the current tick's own message. Verified + // by driving r1 with a fixed preMessage 'A' and r2 with a different fixed preMessage + // 'B'. If the lookup ran after the insert, r2's suffix would reference 'B' (its own + // base_message, distance 0). With the correct ordering, the suffix references 'A'. + const formatter1 = recordingFixedFormatter('first tick message — A'); + const poster = fakeDiscordPoster(); + + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter: formatter1, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + poster.posted.length = 0; + const formatter2 = recordingFixedFormatter('second tick message — B'); + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['73F']), fakeSourceFetcher('crypto', ['67500.00'])], + poster, + formatter: formatter2, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r2', + now: () => 2000, + }); + + expect(poster.posted).toHaveLength(1); + // The suffix references the r1 base_message, not the r2 one. + expect(poster.posted[0]).toContain('Reminds me of: first tick message — A'); + expect(poster.posted[0]).not.toContain('Reminds me of: second tick message — B'); + ctx.cleanup(); + }); + + it('one source failing: the other contributes; formatter receives only the live reading', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [ + throwingSourceFetcher('weather', 'fetch timeout'), + fakeSourceFetcher('crypto', ['67234.10']), + ], + poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(1); + expect(formatter.calls[0]?.readings.map((r) => r.source)).toEqual(['crypto']); + expect(poster.posted).toHaveLength(1); + + const reopened = openDatabase(ctx.dbPath); + const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; + expect(run.error).toMatch(/weather/); + const notifications = reopened.prepare(`SELECT source FROM agent_notifications`).all() as Array<{ source: string }>; + expect(notifications).toEqual([{ source: 'crypto' }]); + reopened.close(); + ctx.cleanup(); + }); + + it('all sources failing: formatter not called, no post, no notifications, notificationsSent = 0', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [ + throwingSourceFetcher('weather', 'fetch timeout'), + throwingSourceFetcher('crypto', 'rate limited'), + ], + poster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(0); + expect(formatter.calls).toHaveLength(0); + expect(poster.posted).toHaveLength(0); + + const reopened = openDatabase(ctx.dbPath); + const notifications = reopened.prepare(`SELECT * FROM agent_notifications`).all(); + expect(notifications).toHaveLength(0); + reopened.close(); + ctx.cleanup(); + }); + + it('formatter failure: caught, no RAG, no post, no notifications, snapshot published', async () => { + const poster = fakeDiscordPoster(); + const failingFormatter: MessageFormatter = { + async format(): Promise { + throw new Error('LLM exploded'); + }, + }; + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter: failingFormatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(0); + expect(poster.posted).toHaveLength(0); + + const reopened = openDatabase(ctx.dbPath); + const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; + expect(run.error).toMatch(/LLM exploded/); + const notifications = reopened.prepare(`SELECT * FROM agent_notifications`).all(); + expect(notifications).toHaveLength(0); + reopened.close(); + ctx.cleanup(); + }); + + it('Titan embed failure: caught, post still happens with no suffix, embedder called once total, no agent_embeddings this tick', async () => { + const formatter = recordingFixedFormatter(); + const poster = fakeDiscordPoster(); + let embedCalls = 0; + const failingEmbedder: Embedder = { + async embed(): Promise { + embedCalls++; + throw new Error('Titan throttled'); + }, + }; + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster, + formatter, + embedder: failingEmbedder, + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(1); + expect(poster.posted).toHaveLength(1); + expect(poster.posted[0]).not.toContain('Reminds me of'); + + // The embedder was called exactly once (the failed call), not a second time as a + // fallback. This pins the spec's "no second Titan call" rule for the RAG failure path. + expect(embedCalls).toBe(1); + + const reopened = openDatabase(ctx.dbPath); + const rows = reopened + .prepare(`SELECT nearest_match_id, base_message FROM agent_notifications`) + .all() as Array<{ nearest_match_id: number | null; base_message: string }>; + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.nearest_match_id).toBeNull(); + // The fixed formatter always returns the same string — no need to re-invoke it. + expect(row.base_message).toBe( + 'A short friendly comment. Today the weather is 72F and BTC is 67234.10. The vibe is calm, the city is bright, the work goes on.', + ); + } + const embCount = (reopened.prepare(`SELECT COUNT(*) AS c FROM agent_embeddings`).get() as { c: number }).c; + expect(embCount).toBe(0); // no pre-vector → no insert + reopened.close(); + ctx.cleanup(); + }); + + it('post failure: caught, no per-source rows, no embeddings, snapshot published', async () => { + const formatter = recordingFixedFormatter(); + const throwingPoster = { + async post(): Promise { + throw new Error('Discord 500'); + }, + }; + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F']), fakeSourceFetcher('crypto', ['67234.10'])], + poster: throwingPoster, + formatter, + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + expect(result.notificationsSent).toBe(0); + + const reopened = openDatabase(ctx.dbPath); + const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; + expect(run.error).toMatch(/Discord 500/); + const notifications = reopened.prepare(`SELECT * FROM agent_notifications`).all(); + expect(notifications).toHaveLength(0); + const embCount = (reopened.prepare(`SELECT COUNT(*) AS c FROM agent_embeddings`).get() as { c: number }).c; + expect(embCount).toBe(0); + reopened.close(); + ctx.cleanup(); + }); + + it('conditional write 412: no upload, previous version untouched, run marked error', async () => { + // First run publishes v1. + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r1', + now: () => 1000, + }); + const v1 = await ctx.store.get('memory.db'); + + // Simulate a concurrent writer: append a byte so the next run's captured etag is stale. + await ctx.store.put('memory.db', Buffer.concat([v1!.body, Buffer.from('x')]), v1!.etag); + + const staleStore = { + ...ctx.store, + async get() { + return v1; + }, + }; + + await expect( + runFetch({ + dbPath: ctx.dbPath, + store: staleStore, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['73F'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + weatherLocation: 'NYC', + runId: 'r2', + now: () => 2000, + }), + ).rejects.toThrow(/PreconditionFailed/); + + const current = await ctx.store.get('memory.db'); + expect(current?.body.length).toBe(v1!.body.length + 1); + + const reopened = openDatabase(ctx.dbPath); + const runRow = reopened.prepare(`SELECT outcome, error FROM agent_runs WHERE run_id = 'r2'`).get() as { outcome: string; error: string }; + expect(runRow.outcome).toBe('error'); + expect(runRow.error).toMatch(/PreconditionFailed/); + reopened.close(); + ctx.cleanup(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npm test -- tests/fetch.test.ts` +Expected: FAIL — `runFetch` does not yet accept `weatherLocation`, still has the per-source loop, and the helper imports won't resolve. + +- [ ] **Step 3: Replace `src/agent/fetch.ts`** + +Replace `src/agent/fetch.ts` (currently 182 lines) with the following: + +```typescript +import { randomUUID } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import type Database from 'better-sqlite3'; +import { bootstrap } from '../db/bootstrap.js'; +import { openDatabase } from '../db/open.js'; +import type { Embedder } from '../embed/titan.js'; +import type { DiscordPoster } from '../discord/poster.js'; +import type { LoopContext, MessageFormatter } from '../format/types.js'; +import { findNearestMatch, insertEmbedding } from '../rag/similarity.js'; +import type { SourceFetcher, SourceName } from '../sources/types.js'; +import type { Store } from '../store/types.js'; +import { finishRun, startRun } from './runLog.js'; + +export interface RunFetchParams { + dbPath: string; + store: Store; + storeKey: string; + sources: SourceFetcher[]; + poster: DiscordPoster; + formatter: MessageFormatter; + embedder: Embedder; + /** The configured location (e.g. "NYC") — passed through to the `LoopContext` so the + * LLM can weave it into the friendly comment and haiku. Loop-mode + poetic-closing + * spec §4.4. */ + weatherLocation: string; + runId?: string; + now?: () => number; +} + +export interface RunFetchResult { + outcome: 'success' | 'error'; + sourcesChecked: number; + notificationsSent: number; + error: string | null; +} + +/** + * The writer op (spec §3.1, loop-mode + poetic-closing spec §4.4). Hydrates from the + * store, runs bootstrap, fetches every source, formats ONCE with a `LoopContext`, + * embeds the LLM's pre-suffix output ONCE, runs a global KNN lookup, appends a + * mechanical "Reminds me of" suffix if a match exists, posts ONCE, writes per-source + * `agent_notifications` rows (each with the same combined `formatted_message` / + * `base_message` / `nearest_match_id`), reuses the pre-vector to insert per-source + * embeddings, and publishes the snapshot back with a conditional write. + * + * Per-source failures (fetch only — there is no per-source formatter or post) + * are caught individually and folded into `agent_runs.error`; the run still completes + * and outcome stays `'success'`. Tick-level failures (formatter, embed, post) are + * caught and the rest of the tick is skipped. Only a `PreconditionFailedError` from + * the final publish propagates — that is an abort, not a tick-level failure. + */ +export async function runFetch(params: RunFetchParams): Promise { + const runId = params.runId ?? randomUUID(); + const now = params.now ?? (() => Date.now()); + + // Step 1: hydrate. + const existing = await params.store.get(params.storeKey); + if (existing !== null) { + writeFileSync(params.dbPath, existing.body); + } + const priorEtag: string | null = existing?.etag ?? null; + + // Step 2-3: open and bootstrap. Bootstrap is idempotent, so this is correct whether the + // file was just hydrated or is a fresh empty file (spec §4.1). + const db: Database.Database = openDatabase(params.dbPath); + bootstrap(db); + + // Step 4: start the run record. + startRun(db, { + runId, + op: 'fetch', + snapshotVersionIn: priorEtag ?? 'none', + startedAt: now(), + }); + + const errors: string[] = []; + + // Step 5: per-source fetch. Per-source failures are caught individually; the live + // readings feed step 7's LoopContext. + const readings = new Map(); + for (const source of params.sources) { + try { + readings.set(source.name, await source.fetch()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${source.name}: ${message}`); + } + } + + // Step 6: if every source failed, the rest of the tick is a no-op. + if (readings.size === 0) { + finishRun(db, { + runId, + endedAt: now(), + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; ') || null, + }); + db.close(); + + const body = readFileSync(params.dbPath); + try { + await params.store.put(params.storeKey, body, priorEtag); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const dbForFailure = openDatabase(params.dbPath); + finishRun(dbForFailure, { + runId, + endedAt: now(), + outcome: 'error', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.length > 0 ? `${errors.join('; ')}; ${message}` : message, + }); + dbForFailure.close(); + throw error; + } + + return { + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; ') || null, + }; + } + + // Step 7: build LoopContext and format ONCE (no RAG fields). A formatter failure + // skips the rest of the tick. + const loopContext: LoopContext = { + date: new Date(now()).toISOString().slice(0, 10), + location: params.weatherLocation, + readings: [...readings.entries()].map(([source, value]) => ({ source, value })), + }; + + let preMessage: string; + try { + preMessage = await params.formatter.format(loopContext); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`formatter: ${message}`); + finishRun(db, { + runId, + endedAt: now(), + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; '), + }); + db.close(); + return publish(db, params, priorEtag, runId, now, 0, errors); + } + + // Step 8: two-step RAG. Embed the LLM's pre-suffix output ONCE for the tick, then + // run a global KNN lookup. A failure here skips the suffix but keeps the post. + let preVector: number[] | null = null; + type RAGMatch = { notificationId: number; distance: number; baseMessage: string; postedAt: number }; + let match: RAGMatch | null = null; + try { + preVector = await params.embedder.embed(preMessage); + match = findNearestMatch(db, preVector); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`rag: ${message}`); + // preVector stays null — step 13's embedding insert is skipped for this tick. + } + + // Step 9: build the final message. The suffix is built from past base_message + // (never past formatted_message), so the chain cannot snowball. + const finalMessage = + match !== null ? `${preMessage}\n\nReminds me of: ${match.baseMessage}` : preMessage; + + // Step 10: post once. + try { + await params.poster.post(finalMessage); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`post: ${message}`); + finishRun(db, { + runId, + endedAt: now(), + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 0, + error: errors.join('; '), + }); + db.close(); + return publish(db, params, priorEtag, runId, now, 0, errors); + } + + // Step 11: per-source DB writes. agent_sources must be upserted BEFORE + // agent_notifications, because the latter has a FK on the former. + const postedAt = now(); + for (const [sourceName, value] of readings) { + db.prepare( + `INSERT INTO agent_sources (name, last_value, last_fetched_at, last_posted_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + last_value = excluded.last_value, + last_fetched_at = excluded.last_fetched_at, + last_posted_at = excluded.last_posted_at`, + ).run(sourceName, value, postedAt, postedAt); + + const insertResult = db + .prepare( + `INSERT INTO agent_notifications + (source, value, formatted_message, base_message, posted_at, + nearest_match_id, nearest_match_distance) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + sourceName, + value, + finalMessage, + preMessage, + postedAt, + match?.notificationId ?? null, + match?.distance ?? null, + ); + + // Step 12: per-source embedding insert. Reuses preVector — no second Titan call. + // Per-source insert failures are caught individually so the notification row + // committed in step 11 stays. + if (preVector !== null) { + try { + insertEmbedding(db, Number(insertResult.lastInsertRowid), preVector); + } catch (storeError: unknown) { + const message = storeError instanceof Error ? storeError.message : String(storeError); + errors.push(`${sourceName} (embedding store): ${message}`); + } + } + } + + // Step 13: finish the run record. One combined post per tick, regardless of how many + // sources contributed. + const errorText = errors.length > 0 ? errors.join('; ') : null; + finishRun(db, { + runId, + endedAt: now(), + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 1, + error: errorText, + }); + + db.close(); + + // Step 14: conditional publish. A PreconditionFailedError here is an abort, not a + // tick-level failure (spec §4.2). + const body = readFileSync(params.dbPath); + try { + await params.store.put(params.storeKey, body, priorEtag); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const dbForFailure = openDatabase(params.dbPath); + finishRun(dbForFailure, { + runId, + endedAt: now(), + outcome: 'error', + sourcesChecked: params.sources.length, + notificationsSent: 1, + error: errorText === null ? message : `${errorText}; ${message}`, + }); + dbForFailure.close(); + throw error; + } + + return { + outcome: 'success', + sourcesChecked: params.sources.length, + notificationsSent: 1, + error: errorText, + }; +} + +/** + * Short-circuits the publish step for tick-level failures — reopens the DB, closes it, + * and returns the standard RunFetchResult. The early-return paths in `runFetch` use + * this so the conditional-publish logic only lives in one place. + */ +function publish( + db: Database.Database, + params: RunFetchParams, + priorEtag: string | null, + runId: string, + now: () => number, + notificationsSent: number, + errors: string[], +): Promise { + db.close(); + const body = readFileSync(params.dbPath); + return params.store + .put(params.storeKey, body, priorEtag) + .then(() => ({ + outcome: 'success' as const, + sourcesChecked: params.sources.length, + notificationsSent, + error: errors.length > 0 ? errors.join('; ') : null, + })) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const dbForFailure = openDatabase(params.dbPath); + finishRun(dbForFailure, { + runId, + endedAt: now(), + outcome: 'error', + sourcesChecked: params.sources.length, + notificationsSent, + error: errors.length > 0 ? `${errors.join('; ')}; ${message}` : message, + }); + dbForFailure.close(); + throw error; + }); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test -- tests/fetch.test.ts` +Expected: PASS — all twelve cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/agent/fetch.ts tests/fetch.test.ts +git commit -m "feat(fetch): combined-message + two-step RAG + base_message separation" +``` + +--- + +## Task 7: Wire `weatherLocation` through `localFetch` and `handler` + +**Files:** +- Modify: `src/localFetch.ts` +- Modify: `src/handler.ts` + +- [ ] **Step 1: Update `src/localFetch.ts`** + +Replace `src/localFetch.ts` (currently 30 lines) with the following: + +```typescript +import { loadConfig } from './config.js'; +import { runFetch } from './agent/fetch.js'; +import { createLocalEmbedder } from './embed/local.js'; +import { createLocalTemplateFormatter } from './format/local.js'; +import { createFetchDiscordPoster } from './discord/poster.js'; +import { createSourceFetcher } from './sources/index.js'; +import { createLocalStore } from './store/local.js'; + +async function main(): Promise { + const config = loadConfig(); + const store = createLocalStore('/tmp/sqlite-s3-agent-tutorial-store'); + + const result = await runFetch({ + dbPath: config.dbPath, + store, + storeKey: 'memory.db', + sources: config.sources.map((name) => createSourceFetcher(name, config.weatherLocation)), + poster: createFetchDiscordPoster(config.discordWebhookUrl), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + weatherLocation: config.weatherLocation, + }); + + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); +``` + +- [ ] **Step 2: Update `src/handler.ts`** + +In `src/handler.ts`, find the `runFetch({ ... })` call site (one place — the `fetch` branch of the `runHandler` function). Replace the existing block: + +```typescript + const result = await runFetch({ + dbPath: config.dbPath, + store, + storeKey: config.snapshotKey, + sources, + poster: createFetchDiscordPoster(config.discordWebhookUrl), + formatter, + embedder, + }); +``` + +with: + +```typescript + const result = await runFetch({ + dbPath: config.dbPath, + store, + storeKey: config.snapshotKey, + sources, + poster: createFetchDiscordPoster(config.discordWebhookUrl), + formatter, + embedder, + weatherLocation: config.weatherLocation, + }); +``` + +(`weatherLocation: config.weatherLocation` is the only addition — the rest of the call site is unchanged.) + +- [ ] **Step 3: Run typecheck** + +Run: `npm run typecheck` +Expected: PASS — both call sites now pass `weatherLocation`. + +- [ ] **Step 4: Commit** + +```bash +git add src/localFetch.ts src/handler.ts +git commit -m "feat(fetch): thread weatherLocation through localFetch and handler" +``` + +--- + +## Task 8: Update handler tests for "ONE formatter call per tick" + +**Files:** +- Modify: `tests/handler.test.ts` + +- [ ] **Step 1: Update the multi-source assertion** + +The existing test `'runs an HTTP-triggered fetch when the token matches'` already mocks `ConverseCommand`. The existing single-source assertions (`'routes op="fetch" through the writer and returns 200'`, `'routes op="status" through the reader …'`, `'runs an HTTP-triggered fetch when the token matches'`, `'runs an EventBridge-triggered fetch …'`) all use `SOURCES: '["weather"]'` — formatter call count is 1 in both old and new designs, so they don't need to change. + +Add one new test that asserts ONE formatter call across two sources (the new spec's "one combined message per tick" guarantee at the handler level). Append it inside the `describe('runHandler', ...)` block, after the existing `'runs an EventBridge-triggered fetch regardless of FETCH_TRIGGER_TOKEN …'` test: + +```typescript + it('makes exactly one Converse call per tick even with two sources configured', async () => { + s3.on(GetObjectCommand).rejects({ name: 'NoSuchKey' }); // bootstrap + s3.on(PutObjectCommand).resolves({ ETag: '"v1"' }); + bedrock.on(ConverseCommand).resolves({ + output: { message: { role: 'assistant', content: [{ text: 'A short friendly comment.\n\nA haiku here.' }] } }, + stopReason: 'end_turn', + }); + + const env = { + DISCORD_WEBHOOK_URL: 'https://discord.example/webhook', + SNAPSHOT_BUCKET: 'test-bucket', + DB_PATH: join(dir, 'memory.db'), + SOURCES: '["weather", "crypto"]', + }; + + const result = await runHandler( + { op: 'fetch' }, + env, + { s3Client: s3 as unknown as S3Client, bedrockClient: bedrock as unknown as BedrockRuntimeClient }, + { + weather: async () => '72F', + crypto: async () => '67234.10', + }, + ); + + expect(result.statusCode).toBe(200); + // The combined-message reformulation means exactly one formatter call per tick, + // regardless of source count — the per-source loop is gone. + expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(1); + }); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `npm test -- tests/handler.test.ts` +Expected: PASS — all existing tests continue to pass, and the new test asserts the new "one Converse call per tick" semantics. + +- [ ] **Step 3: Commit** + +```bash +git add tests/handler.test.ts +git commit -m "test(handler): assert one Converse call per tick with two sources" +``` + +--- + +## Task 9: Update `infra/stack.ts` — schedule, output, timeout + +**Files:** +- Modify: `infra/stack.ts` + +- [ ] **Step 1: Change the schedule to 5 minutes** + +In `src/handler.ts` (no — actually `infra/stack.ts`), find the `new events.Rule(this, 'FetchSchedule', { ... })` block. Replace the `schedule:` line: + +```typescript + schedule: events.Schedule.rate(cdk.Duration.minutes(5)), +``` + +(Replaces the existing `events.Schedule.rate(cdk.Duration.days(1))`. No other change inside the `Rule` block — `enabled: true` stays, the autogenerated `ruleName` is unchanged, the `targets` block and `retryAttempts: 0` stay.) + +- [ ] **Step 2: Bump the Lambda timeout to 60 seconds** + +In the `agentFunction = new lambda.DockerImageFunction(this, 'AgentFunction', { ... })` block, replace the `timeout:` line: + +```typescript + timeout: cdk.Duration.seconds(60), +``` + +(Replaces the existing `timeout: cdk.Duration.seconds(30)`.) + +- [ ] **Step 3: Add the `LoopRuleName` `CfnOutput`** + +The `FetchSchedule` rule is referenced at construction (`new events.Rule(this, 'FetchSchedule', { ... })`). To add an output that references `ruleName`, the rule reference must be captured. Change the rule construction to: + +```typescript + const fetchSchedule = new events.Rule(this, 'FetchSchedule', { + enabled: true, + schedule: events.Schedule.rate(cdk.Duration.minutes(5)), + targets: [ + new targets.LambdaFunction(agentFunction, { + event: events.RuleTargetInput.fromObject({ op: 'fetch' }), + // Spec §6: EventBridge retries on invocation failure are disabled for this op + // — the failure is informational, not transient. Without this, CDK's default + // is 185 retries over ~24 hours, and a 412 from Store.put would replay. + retryAttempts: 0, + }), + ], + }); +``` + +Then append a new `CfnOutput` after the existing three outputs (after `AgentFunctionUrl`): + +```typescript + new cdk.CfnOutput(this, 'LoopRuleName', { value: fetchSchedule.ruleName }); +``` + +- [ ] **Step 4: Run typecheck** + +Run: `npm run typecheck` +Expected: PASS — `fetchSchedule` is the rule's `events.Rule` instance, and `ruleName` is a public string property on it. + +- [ ] **Step 5: Commit** + +```bash +git add infra/stack.ts +git commit -m "feat(infra): 5-min schedule, LoopRuleName output, 60s lambda timeout" +``` + +--- + +## Task 10: Create `scripts/loop-start.sh` and `scripts/loop-stop.sh` + +**Files:** +- Create: `scripts/loop-start.sh` +- Create: `scripts/loop-stop.sh` + +- [ ] **Step 1: Create `scripts/loop-stop.sh`** + +Create `scripts/loop-stop.sh` with the following contents: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +PROFILE="${AWS_PROFILE:-default}" +REGION="${AWS_REGION:-us-east-1}" +STACK_NAME="SqliteS3AgentTutorial" + +echo "=== Fetching loop rule name ===" +RULE_NAME=$(aws cloudformation describe-stacks \ + --profile "$PROFILE" \ + --region "$REGION" \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='LoopRuleName'].OutputValue" \ + --output text) + +if [ -z "$RULE_NAME" ] || [ "$RULE_NAME" = "None" ]; then + echo "FAIL: stack $STACK_NAME has no LoopRuleName output — is the loop feature already deployed?" >&2 + exit 1 +fi + +echo "Rule: $RULE_NAME" + +echo "" +echo "=== Disabling loop rule ===" +aws events disable-rule \ + --name "$RULE_NAME" \ + --profile "$PROFILE" \ + --region "$REGION" + +echo "" +echo "=== Confirming rule state ===" +STATE=$(aws events describe-rule \ + --name "$RULE_NAME" \ + --query State \ + --output text \ + --profile "$PROFILE" \ + --region "$REGION") + +echo "Rule $RULE_NAME is now: $STATE" +echo "" +echo "Note: running 'npm run deploy' after this script re-enables the rule, since the" +echo "CDK stack declares it 'enabled: true'. Re-run this script after any redeploy if" +echo "you want the loop to stay off." +``` + +- [ ] **Step 2: Create `scripts/loop-start.sh`** + +Create `scripts/loop-start.sh` with the following contents (mirror of `loop-stop.sh`, with `enable-rule` instead of `disable-rule`): + +```bash +#!/usr/bin/env bash +set -euo pipefail + +PROFILE="${AWS_PROFILE:-default}" +REGION="${AWS_REGION:-us-east-1}" +STACK_NAME="SqliteS3AgentTutorial" + +echo "=== Fetching loop rule name ===" +RULE_NAME=$(aws cloudformation describe-stacks \ + --profile "$PROFILE" \ + --region "$REGION" \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='LoopRuleName'].OutputValue" \ + --output text) + +if [ -z "$RULE_NAME" ] || [ "$RULE_NAME" = "None" ]; then + echo "FAIL: stack $STACK_NAME has no LoopRuleName output — is the loop feature already deployed?" >&2 + exit 1 +fi + +echo "Rule: $RULE_NAME" + +echo "" +echo "=== Enabling loop rule ===" +aws events enable-rule \ + --name "$RULE_NAME" \ + --profile "$PROFILE" \ + --region "$REGION" + +echo "" +echo "=== Confirming rule state ===" +STATE=$(aws events describe-rule \ + --name "$RULE_NAME" \ + --query State \ + --output text \ + --profile "$PROFILE" \ + --region "$REGION") + +echo "Rule $RULE_NAME is now: $STATE" +``` + +- [ ] **Step 3: Make both scripts executable** + +Run: `chmod +x scripts/loop-start.sh scripts/loop-stop.sh` +Expected: exit 0, no output. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/loop-start.sh scripts/loop-stop.sh +git commit -m "feat(scripts): loop-start.sh and loop-stop.sh toggle EventBridge rule" +``` + +--- + +## Task 11: Add loop scripts to `package.json` + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add the two scripts** + +In `package.json`, locate the `"scripts"` block. Add two new entries after the existing `"smoke"` line: + +```json + "loop-start": "bash scripts/loop-start.sh", + "loop-stop": "bash scripts/loop-stop.sh", +``` + +The result (excerpt): + +```json + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.check.json", + "test": "vitest run", + "test:watch": "vitest", + "local-fetch": "tsx src/localFetch.ts", + "cdk": "cdk", + "deploy": "bash scripts/deploy.sh", + "smoke": "bash scripts/smoke.sh", + "loop-start": "bash scripts/loop-start.sh", + "loop-stop": "bash scripts/loop-stop.sh" + }, +``` + +- [ ] **Step 2: Install nothing; the scripts only use AWS CLI** + +No `npm install` is needed — the AWS CLI is the only runtime dependency, and `smoke.sh` already requires it. + +- [ ] **Step 3: Commit** + +```bash +git add package.json +git commit -m "chore(package): expose loop-start and loop-stop npm scripts" +``` + +--- + +## Task 12: Add "Loop mode" subsection to `README.md` + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Insert the new subsection** + +In `README.md`, locate the `## Triggering a fetch on demand` section. Insert a new `## Loop mode` section immediately before it (after the `### Quick start` block). The new section reads: + +```markdown +## Loop mode + +For local testing and quick iteration, the agent can run a 5-minute loop instead of +the once-daily schedule. After the same `npm run deploy` as for the daily schedule, +toggle the loop on and off directly from your shell — no Lambda invocation, no token, +no extra IAM grants: + +```bash +npm run loop-start # calls `aws events enable-rule` on the deployed rule +npm run loop-stop # calls `aws events disable-rule` — no further ticks +``` + +While the loop is running, each tick posts one combined Discord message: a short +friendly comment drawn from today's date, weather, and crypto price, ending with a +haiku. If a past message in the corpus is close enough, the LLM's pre-suffix output +is mechanically appended with a `Reminds me of: ` line. Both scripts +read the rule name from the `LoopRuleName` stack output and call the EventBridge API +directly using the same AWS CLI credentials the smoke script already requires. + +**Stop the loop when you're done** — `loop-stop.sh` disables the EventBridge rule so +no further invocations occur and the recurring AWS cost stops. Note: running +`npm run deploy` after `loop-stop.sh` re-enables the rule, since the CDK stack +declares it `enabled: true` — re-run `loop-stop.sh` after any redeploy if you want +the loop to stay off. See [docs/07-budget-protection.md](docs/07-budget-protection.md) +for the per-day Bedrock call rate at 5-min cadence. +``` + +- [ ] **Step 2: Run the markdown link check (manual)** + +Search for broken internal links in `README.md`: + +Run: `grep -n '\](\([^h]\|http\)' README.md` +Expected: the only links are to `docs/...` files and the existing table-of-contents entries — no broken references. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(readme): add Loop mode subsection with start/stop scripts" +``` + +--- + +## Task 13: Add loop-cost paragraph to `docs/07-budget-protection.md` + +**Files:** +- Modify: `docs/07-budget-protection.md` + +- [ ] **Step 1: Insert a new bullet in "What can actually drive cost up"** + +In `docs/07-budget-protection.md`, locate the `## What can actually drive cost up` bullet list. Add a fourth bullet immediately after the existing three bullets (the `SOURCES` / `BEDROCK_MAX_OUTPUT_TOKENS` one): + +```markdown +- **Loop mode running unattended.** Switching the 5-minute loop on (`npm run loop-start`) + drives ~576 Bedrock calls per day (1 Converse + 1 Titan per tick × 288 ticks/day) and + grows both `agent_notifications` and `agent_embeddings` by ~576 rows each per day + (~1,152 rows/day combined). At default model pricing this is roughly $0.02–$0.04/day, + but a loop left running for a weekend amplifies the spend noticeably. `npm run + loop-stop` disables the EventBridge rule so no further ticks fire — re-run it after + any `npm run deploy` that re-enables the rule (see the redeploy caveat in the + README's Loop mode section). +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/07-budget-protection.md +git commit -m "docs(budget): note 5-min loop Bedrock-call rate and row growth" +``` + +--- + +## Task 14: Final verification + +**Files:** +- (none — runs the full check suite) + +- [ ] **Step 1: Run a clean install and full typecheck** + +Run: `rm -rf node_modules && npm install && npm run typecheck` +Expected: install completes; `typecheck` exits 0 with no errors. + +- [ ] **Step 2: Run the full test suite** + +Run: `npm test` +Expected: every test file passes. The full list: + +- `tests/bedrock.test.ts` — 8 cases +- `tests/config.test.ts` — unchanged +- `tests/db.test.ts` — existing cases + the new `base_message` test from Task 1 +- `tests/discord.test.ts` — unchanged +- `tests/families.test.ts` — unchanged +- `tests/fetch.test.ts` — 12 cases (rewritten in Task 6) +- `tests/format.test.ts` — 4 cases (rewritten in Task 4) +- `tests/handler.test.ts` — existing cases + 1 new case from Task 8 +- `tests/runLog.test.ts` — unchanged +- `tests/s3.test.ts` — unchanged +- `tests/similarity.test.ts` — 7 cases (rewritten in Task 2) +- `tests/status.test.ts` — unchanged +- `tests/store.test.ts` — unchanged +- `tests/titan.test.ts` — unchanged + +- [ ] **Step 3: Confirm the local writer end-to-end** + +Run: `set -a; . ./.env; set +a && npm run local-fetch` +Expected: a single-line JSON result with `outcome: "success"`, `notificationsSent: 1`, +and `error: null` (or with one source's actual error if the network is offline — +that's an environmental failure, not a code bug). The path of the local SQLite file +remains `/tmp/sqlite-s3-agent-tutorial-store/memory.db` after the run. + +- [ ] **Step 4: Grep for placeholders** + +Run: `grep -rn 'TBD\|TODO\|FIXME' src/ tests/ infra/ scripts/ docs/` +Expected: no new occurrences beyond comments that are pre-existing or that explicitly +mark deferred work (e.g. "out of scope per spec §9"). The spec's open concerns +(RAG corpus bloat, per-source dedup, etc.) are documented in the spec itself, not +left as code TODOs. + +- [ ] **Step 5: Review the diff** + +Run: `git log --oneline -14` +Expected: 14 commits, each one a self-contained change from the tasks above: + +1. `feat(schema): add base_message column for snowball-free RAG suffix` +2. `feat(rag): global KNN returns baseMessage, filter null base_message` +3. `refactor(format): introduce LoopContext, remove SimilarPastResult` +4. `refactor(format): local template uses LoopContext, drops LABELS` +5. `feat(format): Bedrock formatter uses LoopContext, system prompt asks for haiku` +6. `feat(fetch): combined-message + two-step RAG + base_message separation` +7. `feat(fetch): thread weatherLocation through localFetch and handler` +8. `test(handler): assert one Converse call per tick with two sources` +9. `feat(infra): 5-min schedule, LoopRuleName output, 60s lambda timeout` +10. `feat(scripts): loop-start.sh and loop-stop.sh toggle EventBridge rule` +11. `chore(package): expose loop-start and loop-stop npm scripts` +12. `docs(readme): add Loop mode subsection with start/stop scripts` +13. `docs(budget): note 5-min loop Bedrock-call rate and row growth` + +Run: `git diff main..HEAD --stat` +Expected: every file listed under "File Structure" at the top of this plan has at +least one changed line. + +- [ ] **Step 6: Done** + +The loop-mode + poetic-closing feature is implemented, documented, and test-covered. +`npm run typecheck && npm test` is green. The README and `docs/07-budget-protection.md` +carry the user-facing notes; the design spec remains the authoritative source for +what was built and why. diff --git a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md index bb3cc38..a84bd83 100644 --- a/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md +++ b/docs/superpowers/specs/2026-08-09-loop-mode-poetry-design.md @@ -1,8 +1,8 @@ # Loop Mode + Poetic Closing — Design **Date:** 2026-08-09 -**Status:** Approved -**Scope:** Replaces the once-daily schedule with a 5-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + haiku, with a mechanical "Reminds me of: $similar" suffix that points at the closest past message), and adds commands to start and stop the loop. `loop-stop` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. One Discord message per tick (combined weather + crypto + haiku, plus optional "Reminds me of" suffix), no dedup, no new user-facing tutorial doc. The loop token is auto-generated by the CDK stack at synth time so the user doesn't have to manage a secret. +**Status:** Implemented +**Scope:** Replaces the once-daily schedule with a 5-minute loop, makes the Discord message more varied and fun (date + location + weather + crypto + haiku, with a mechanical "Reminds me of: $similar" suffix that points at the closest past message), and adds scripts to start and stop the loop. `loop-stop.sh` actually disables the EventBridge rule so the recurring AWS invocations stop — the loop shuts down in AWS, not just in the Lambda. One Discord message per tick (combined per-source readings + haiku, plus optional "Reminds me of" suffix), no dedup, no new user-facing tutorial doc. The control plane is pure AWS CLI in the scripts — no Lambda-side ops, no token, no new IAM permissions. --- @@ -17,34 +17,35 @@ The base tutorial posts one Discord message per day, on value change only. For l - **One writer, no dedup, one message per tick.** The existing `runFetch` is the only writer. It always fetches all sources, always formats ONCE with a clean context (no RAG), then runs the RAG lookup on the LLM's output and appends a "Reminds me of" suffix mechanically. The Discord channel gets one message per tick. Per-source `agent_notifications` rows are still written (one per source per tick) so the status endpoint and RAG corpus work unchanged, but they all carry the same combined `formatted_message` and the same RAG match. - **Two-step RAG: format first, then look up.** The LLM is never asked to reference a closest past reading. The RAG lookup happens after the format call: the writer embeds the LLM's output, queries the KNN corpus (no per-source filter), and appends `\n\nReminds me of: ` to the LLM's output before posting. This keeps the LLM prompt simple, the cosine similarity tight (query and corpus both key on the LLM's output text), and the suffix mechanical rather than LLM-driven. - **No suffix snowball.** The LLM's pre-suffix output is stored in a new `base_message` column on `agent_notifications` and is the only thing that gets embedded into the RAG corpus. The `formatted_message` column continues to hold the full posted message (with "Reminds me of" suffix if present). The RAG match returns the past tick's `base_message` for the suffix, never its `formatted_message`. Because `base_message` is always the LLM's clean pre-suffix output, its size is bounded (~150 chars), the "Reminds me of" suffix stays bounded, and the posted message never grows past one base message + one suffix. Without this separation, appending "Reminds me of: $past" each tick recursively accumulates past messages and the posted text grows past Discord's 2000-char limit after ~13 ticks, jamming the loop. -- **`loop-stop` actually disables the EventBridge rule.** A stopped loop means no further scheduled invocations, not a Lambda that returns early. Same Lambda, same Function URL, just no trigger firing. `loop-start` re-enables it. -- **Loop token is auto-generated, not user-set.** The CDK stack generates `LOOP_TOKEN` at synth time using a secure RNG, passes it to the Lambda env, and exposes it as a stack output. The `loop-start.sh` / `loop-stop.sh` scripts read the token from stack outputs at runtime — the user just runs the script, no `export LOOP_TOKEN=...` required. The token is regenerated on each redeploy; this is acceptable for a single-user tutorial and the deliberate trade-off vs managing a secret manually. -- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the script commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~480 Bedrock calls/day while running. No new `docs/0X-*.md`. +- **`loop-stop.sh` actually disables the EventBridge rule, with no Lambda involved.** A stopped loop means no further scheduled invocations, not a Lambda that returns early. The control plane is a plain `aws events disable-rule` / `enable-rule` CLI call from the script — there is no Lambda op, no Function URL round-trip, and no application-level auth to bypass. This is a straight simplification over an earlier draft that routed start/stop through the Lambda's Function URL with a generated token: the script already needs AWS CLI credentials to read stack outputs, so it can call EventBridge directly with those same credentials, and the tutorial loses a secret, an IAM grant, and a handler code path for no loss of function. +- **Rule name is CDK-autogenerated, not hardcoded.** The `FetchSchedule` rule keeps CDK's default generated name (no explicit `ruleName` prop). The name is exposed as a `LoopRuleName` stack output; `loop-start.sh` / `loop-stop.sh` read it from there, so the user never types or hardcodes a rule name and the stack stays deployable more than once per account/region without a name collision. +- **No new user-facing tutorial doc.** The base tutorial teaches the pattern, not the loop. The README gets a short "Loop mode" subsection with the script commands; `docs/07-budget-protection.md` gets a one-paragraph note that the loop drives ~576 Bedrock calls/day while running (see §8 for the math). No new `docs/0X-*.md`. - **Numerology is out.** The closing beat is a short haiku the LLM generates, not a numerology sentence. LLMs produce cliché numerology platitudes; a haiku gives them actual creative room and reads as varied across runs. - **No RAG corpus cap in this spec.** The RAG invariant "every posted message is searchable" stays intact. The corpus grows at 5-min cadence while the loop runs, which is fine because the loop is short-lived. A future spec could add a cap or vacuum job; that's out of scope here. +- **A redeploy silently re-enables a stopped loop.** The `FetchSchedule` rule is declared `enabled: true` in CDK. If a user runs `loop-stop.sh` and later runs `npm run deploy` (e.g. to pick up a code change), the rule resource is re-synthesized to its declared `enabled: true` state and the loop resumes — CDK does not know or preserve the imperative `DisableRuleCommand` state from the CLI. This is called out explicitly in §6 and in the README so a reader isn't surprised by a resumed AWS bill after an unrelated redeploy. --- ## 2. Architecture The deploy changes (all in `infra/stack.ts`): -- The existing `FetchSchedule` EventBridge rule is given an explicit `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` (so the handler can refer to it stably), and its schedule is changed from `rate(1 day)` to `rate(5 minutes)`. Same Lambda target, same `{op:"fetch"}` payload. -- The Lambda role gains two new permissions: `events:EnableRule` and `events:DisableRule`, scoped to that rule's ARN. -- The Lambda environment gains `LOOP_RULE_NAME` (the rule's full name), `LOOP_TOKEN` (a 24-byte hex value generated at synth time via `crypto.randomBytes(24).toString('hex')`), and `LOOP_RULE_NAME`. Both are also exposed as CloudFormation outputs (`LoopToken`, `LoopRuleName`). -- The Lambda timeout is bumped from 30s to 60s. Haiku generation plus two Titan calls plus S3 GET/PUT plus the Discord post is comfortably under 30s in the typical case, but the extra headroom protects against transient Bedrock latency without overlapping 5-minute ticks. +- The existing `FetchSchedule` EventBridge rule keeps its CDK-autogenerated name (no explicit `ruleName` prop), and its schedule is changed from `rate(1 day)` to `rate(5 minutes)`. Same Lambda target, same `{op:"fetch"}` payload. +- No new IAM permissions and no Lambda environment changes for the loop feature — the Lambda's role is untouched. Start/stop is entirely a script-side AWS CLI concern (see below). +- A new `CfnOutput`, `LoopRuleName` (`rule.ruleName`), so the scripts can resolve the rule without hardcoding it. +- The Lambda timeout is bumped from 30s to 60s. Haiku generation plus one Titan call plus S3 GET/PUT plus the Discord post is comfortably under 30s in the typical case, but the extra headroom protects against transient Bedrock latency without overlapping 5-minute ticks. The runtime changes: - `runFetch` is restructured to the combined-message, two-step RAG flow. It fetches all sources up front, formats ONCE with a clean context (no RAG), then runs the RAG lookup on the LLM's output, appends the "Reminds me of" suffix if a match exists, posts ONCE, and writes per-source `agent_notifications` rows (each with the same combined `formatted_message` and the same RAG match). The RAG corpus entry uses the LLM's pre-suffix output as the embed text, so the query and the corpus both key on the same text. -- The formatter's user prompt gains structured fields: `date`, `location`, `weatherValue`, `cryptoValue`. The LLM is **not** told about RAG — the closest-past reference is appended mechanically after the LLM call. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, varying phrasing across runs, and (2) end with a brief haiku (5-7-5) that incorporates the temperature, the crypto value, and the day's vibe. +- The formatter's user prompt gains a structured `readings` field (one entry per configured source, e.g. `{source: 'weather', value: '72F'}`) plus `date` and `location` — source-agnostic, so a third source added per `docs/04-extending.md` shows up in the prompt automatically. The LLM is **not** told about RAG — the closest-past reference is appended mechanically after the LLM call. The system prompt instructs the LLM to (1) write a short friendly comment that draws on the inputs, varying phrasing across runs, and (2) end with a brief haiku (5-7-5) that incorporates the readings and the day's vibe. The control plane changes: -- Two new ops, `loop-start` and `loop-stop`, gated by `LOOP_TOKEN` (same constant-time check pattern as `FETCH_TRIGGER_TOKEN`). They call `EventBridge.EnableRuleCommand` / `DisableRuleCommand` on the rule named by `LOOP_RULE_NAME`. They do not touch the SQLite snapshot — EventBridge is the source of truth. They return `{ statusCode: 200, body: JSON.stringify({ loopState: 'ENABLED' | 'DISABLED' }) }` based on the call's result. -- The `loop-start.sh` / `loop-stop.sh` scripts read `FUNCTION_URL` and `LOOP_TOKEN` from stack outputs (matching the existing `smoke.sh` pattern), curl the right op, and print success/failure. +- **No Lambda ops, no handler changes, no token.** Starting/stopping the loop never touches the Lambda or the Function URL. `loop-start.sh` / `loop-stop.sh` resolve `LoopRuleName` from stack outputs (matching the existing `smoke.sh` pattern for reading `AgentFunctionName`/`AgentFunctionUrl`) and call `aws events enable-rule` / `aws events disable-rule` directly, using the same AWS CLI credentials `smoke.sh` already requires. They do not touch the SQLite snapshot — EventBridge is the source of truth. They print the rule's resulting state (via `aws events describe-rule`) so the user can confirm the toggle took effect. ``` EventBridge rate(5 min) ──> Lambda (op:"fetch") │ (rule is ENABLED by default; - │ loop-stop flips it to DISABLED, + │ loop-stop.sh flips it to DISABLED + │ via a direct EventBridge API call, │ which stops all further invocations) ▼ runFetch @@ -54,32 +55,38 @@ EventBridge rate(5 min) ──> Lambda (op:"fetch") │ failed sources are absent from the context) │ ├─ STEP 2: formatter.format(LoopContext) ← ONE LLM call - │ LoopContext = {date, location, weatherValue, cryptoValue} + │ LoopContext = {date, location, readings: [{source, value}, ...]} │ → preMessage (LLM's output: friendly comment + haiku) │ - ├─ STEP 3: embed preMessage, KNN over agent_embeddings - │ (no per-source filter) → nearest past notification + ├─ STEP 3: embed preMessage → preVector (ONE Titan call), + │ KNN over agent_embeddings (no per-source filter, + │ WHERE n.base_message IS NOT NULL — see §4.5) + │ → nearest already-posted notification │ → if match exists: preMessage += "\n\nReminds me of: " + match.baseMessage │ → finalMessage = preMessage [with optional suffix] │ ├─ STEP 4: poster.post(finalMessage) ← ONE post │ - ├─ STEP 5: per-source insert agent_notifications + ├─ STEP 5: per-source upsert agent_sources, then + │ per-source insert agent_notifications │ (formatted_message = finalMessage, - │ same nearest_match_id on all rows) + │ same nearest_match_id on all rows) — agent_sources + │ is written first because agent_notifications has an + │ FK on agent_sources(name) │ - ├─ STEP 6: embed preMessage (NOT finalMessage) and + ├─ STEP 6: reuse preVector (NOT a second Titan call) and │ insertEmbedding per source — query and corpus - │ both key on the LLM's pre-suffix output + │ both key on the LLM's pre-suffix output. Skipped + │ entirely if STEP 3's embed call itself failed + │ (preVector never existed for this tick). │ └─ publish snapshot to S3 (conditional) scripts/loop-stop.sh: - FUNCTION_URL=$(aws cloudformation describe-stacks ... AgentFunctionUrl) - LOOP_TOKEN=$(aws cloudformation describe-stacks ... LoopToken) - curl -X POST "$FUNCTION_URL?token=$LOOP_TOKEN" --data '{"op":"loop-stop"}' - └─ EventBridge.DisableRuleCommand(Name: LOOP_RULE_NAME) - → rule state = DISABLED, no more ticks + RULE_NAME=$(aws cloudformation describe-stacks ... LoopRuleName) + aws events disable-rule --name "$RULE_NAME" + → rule state = DISABLED, no more ticks + aws events describe-rule --name "$RULE_NAME" --query State # printed for confirmation ``` --- @@ -92,7 +99,7 @@ scripts/loop-stop.sh: ALTER TABLE agent_notifications ADD COLUMN base_message TEXT; ``` -The migration is added to `bootstrap()` via the same `PRAGMA table_info` guard the RAG spec uses for its own column additions — SQLite has no `ADD COLUMN IF NOT EXISTS`. A snapshot from before this feature shipped will have `base_message = NULL` on all rows; the status endpoint surfaces null as "—" (pre-loop row); the RAG query ignores them (the corpus embeds only rows with a non-null `base_message`, and any row without one is simply not a match candidate). +The migration is added to `bootstrap()` via the same `PRAGMA table_info` guard the RAG spec uses for its own column additions — SQLite has no `ADD COLUMN IF NOT EXISTS`. A snapshot from before this feature shipped will have `base_message = NULL` on all rows. The status endpoint (`src/agent/status.ts`) is unchanged by this spec (§4.6) and never reads `base_message`, so a null value there has no visible effect on `status` output. The RAG query (§4.5) explicitly excludes rows with a null `base_message` from match candidacy — without that exclusion they would otherwise surface as `agent_embeddings` candidates whose joined `base_message` is null. The two columns separate two distinct things: @@ -111,21 +118,22 @@ Both grow at the same rate as before. A RAG corpus cap or vacuum job is a future ## 4. New / changed modules -### 4.1 `src/config.ts` — new env vars +### 4.1 `src/config.ts` — no new env vars -- `LOOP_TOKEN` (string, required for `loop-start` / `loop-stop` to be reachable, default `null`): gates the new ops. If unset, the handler returns 403 for both ops (default-deny, same posture as `FETCH_TRIGGER_TOKEN`). -- `LOOP_RULE_NAME` (string, optional, default `null`): the EventBridge rule to enable/disable. If `LOOP_TOKEN` is set but `LOOP_RULE_NAME` is empty, `loadConfig` throws at startup. Set by the CDK stack. - -In practice, the CDK stack always sets both. End users see them only as stack outputs. +This spec adds no new environment variables. Loop start/stop is a script-side AWS CLI concern (§4.8) and never reaches the Lambda, so `AgentConfig` and `loadConfig` are unchanged. ### 4.2 `src/format/types.ts` — new `LoopContext` ```typescript +export interface LoopReading { + source: SourceName; + value: string; +} + export interface LoopContext { - date: string; // ISO date, e.g. "2026-08-09" - location: string; // e.g. "NYC" - weatherValue: string; // current weather for the location, e.g. "72F" - cryptoValue: string; // current BTC USD price as a string, e.g. "67234.10" + date: string; // ISO date, e.g. "2026-08-09" + location: string; // e.g. "NYC" + readings: LoopReading[]; // one entry per source that fetched successfully this tick } export interface MessageFormatter { @@ -133,27 +141,31 @@ export interface MessageFormatter { } ``` +`readings` is source-agnostic by design: it's built by mapping over whatever sources succeeded this tick (§4.4 step 2), not by naming `weather`/`crypto` fields. A reader who follows `docs/04-extending.md` to add a third source gets it included in the prompt automatically, with no change to `LoopContext` or the formatter. A source that failed this tick is simply absent from the array rather than represented as `''` — the LLM sees only sources it has real data for. + The signature change is internal — `MessageFormatter` is a TypeScript type used only by `runFetch` and `localFetch` (and the format module's own tests); it is not a public package surface. `LocalTemplateFormatter` and `BedrockFormatter` are updated to match. **RAG is no longer in the formatter's context.** The LLM is not asked to reference a closest past reading. The RAG lookup happens in the writer after the format call; the suffix is appended mechanically. This keeps the LLM prompt clean, the cosine match tight, and the implementation obvious. +`SimilarPastResult` (the old RAG-in-formatter shape) is deleted from `src/format/types.ts` — nothing constructs it anymore. + ### 4.3 `src/format/bedrock.ts` — new system prompt ``` SYSTEM: -You write a short, friendly Discord message for a daily-checkin bot that -posts a combined weather and crypto snapshot every few minutes. The user -message below contains today's date, the location, the current weather, -and the current crypto value. Write a brief comment (one or two sentences) -that draws on these inputs — vary your phrasing across runs; do not repeat -the same template. End with a short haiku (three lines, 5-7-5 syllables) -that weaves in the temperature, the crypto value, and the day's vibe. +You write a short, friendly Discord message for a check-in bot that posts a +combined snapshot of a few tracked values every few minutes. The user +message below contains today's date, the location, and the current value +of each tracked reading. Write a brief comment (one or two sentences) that +draws on these inputs — vary your phrasing across runs; do not repeat the +same template. End with a short haiku (three lines, 5-7-5 syllables) that +weaves in the readings and the day's vibe. Reply with the message text only — no quotes, no preamble, no markdown. ``` No "closest past reading" mention — the LLM doesn't see RAG context, so it doesn't try to reference one. The "Reminds me of" suffix is appended after the LLM call. -The local template formatter is updated to a minimal `"{date} — {weatherValue} / {cryptoValue}"` shape so its tests still pin the new context fields. It is not expected to generate a haiku — it's a test-only stub. +The local template formatter is updated to a minimal `"{date} — {source}: {value}, ..."` shape (one segment per `readings` entry) so its tests still pin the new context fields without hardcoding `weather`/`crypto`. It is not expected to generate a haiku — it's a test-only stub. The old per-source `LABELS` map (`{ weather: 'Weather update', crypto: 'Crypto update' }`) in `src/format/local.ts` is deleted — it has no equivalent in the combined-message shape and nothing references it once `format()`'s signature changes. ### 4.4 `src/agent/fetch.ts` — combined-message, two-step RAG writer @@ -162,46 +174,59 @@ The writer is restructured from "per-source format+post loop" to "fetch all, for New flow: 1. Hydrate, open, bootstrap, start the `agent_runs` row (unchanged). -2. For each source, call `source.fetch()` and collect results into a per-source map. Per-source failures are caught and folded into `errors`; the source is omitted from the map. -3. **If the per-source map is empty** (both sources failed), skip the rest of the tick. Finish the `agent_runs` row with `notificationsSent: 0` and publish the snapshot. -4. Build a `LoopContext` with `date = ISO slice of now()`, `location = config.weatherLocation`, `weatherValue` / `cryptoValue` from the map (or `''` if a source failed). Call `params.formatter.format(ctx)` → `preMessage`. If the format call throws, fold the error into `errors`, skip the rest of the tick. -5. **Two-step RAG.** Call `params.embedder.embed(preMessage)` to get `preVector`. Call `findNearestMatch(db, preVector)` (no per-source filter — global KNN over `agent_embeddings`) to get `{ notificationId, distance, baseMessage: pastBaseMessage }` or `null`. `baseMessage` is the past tick's pre-suffix output — the LLM's clean message, never its `formatted_message`. This is the snowball-prevention key: the suffix is built from `pastBaseMessage`, not from a past `formatted_message` that already contains a "Reminds me of" line. If the embed or lookup throws, fold the error into `errors` and proceed with no suffix. +2. For each source, call `source.fetch()` and collect results into a per-source map (`Map`). Per-source failures are caught and folded into `errors`; the source is omitted from the map. +3. **If the per-source map is empty** (all sources failed), skip the rest of the tick. Finish the `agent_runs` row with `notificationsSent: 0` and publish the snapshot. +4. Build a `LoopContext` with `date = ISO slice of now()`, `location = config.weatherLocation`, and `readings` built from the per-source map (one `{source, value}` entry per source that succeeded — no placeholder for failed sources, see §4.2). Call `params.formatter.format(ctx)` → `preMessage`. If the format call throws, fold the error into `errors`, skip the rest of the tick. +5. **Two-step RAG.** Call `params.embedder.embed(preMessage)` to get `preVector` (ONE Titan call for the whole tick). Call `findNearestMatch(db, preVector)` (no per-source filter — global KNN over `agent_embeddings`, joined to `agent_notifications`, filtered to `base_message IS NOT NULL` — see §4.5) to get `{ notificationId, distance, baseMessage: pastBaseMessage }` or `null`. There is no age floor: any past posted notification is a valid match, including one from a few minutes ago. This is safe because the query runs *before* this tick's own embedding is inserted (step 9 happens after posting) — the corpus at query time contains only notifications that were already posted to Discord in a prior tick, so a match can never be this tick's own not-yet-posted message. `baseMessage` is the past tick's pre-suffix output — the LLM's clean message, never its `formatted_message`. This is the snowball-prevention key: the suffix is built from `pastBaseMessage`, not from a past `formatted_message` that already contains a "Reminds me of" line. If the embed or lookup throws, fold the error into `errors`, proceed with no suffix, and treat `preVector` as unset for step 9 (nothing to reuse). 6. Build `finalMessage = match !== null ? preMessage + "\n\nReminds me of: " + match.baseMessage : preMessage`. -7. Call `params.poster.post(finalMessage)`. If the post throws, fold the error into `errors`, skip the per-source inserts. -8. For each source in the per-source map, insert one `agent_notifications` row with the per-source `value`, `formatted_message = finalMessage`, `base_message = preMessage`, `nearest_match_id = match?.notificationId ?? null`, `nearest_match_distance = match?.distance ?? null`, and the shared `posted_at`. Update `agent_sources` with the per-source last-value/last-fetched/last-posted timestamps. -9. **Embed the LLM's pre-suffix output and store it** under each source label: `insertEmbedding(db, notificationId, preVector)`. The corpus keys on `base_message`, not on `formatted_message` — this is what makes the cosine match tight and what prevents the snowball. Per-source embed/store failures are caught and folded into `errors`; the row stays. +7. Call `params.poster.post(finalMessage)`. If the post throws, fold the error into `errors`, skip the per-source inserts (and step 9). +8. For each source in the per-source map: **first** upsert `agent_sources` (last-value/last-fetched/last-posted timestamps), **then** insert one `agent_notifications` row with the per-source `value`, `formatted_message = finalMessage`, `base_message = preMessage`, `nearest_match_id = match?.notificationId ?? null`, `nearest_match_distance = match?.distance ?? null`, and the shared `posted_at`. The `agent_sources` upsert must happen first — `agent_notifications.source` has a `FOREIGN KEY` on `agent_sources(name)`, so inserting the notification row before the source row exists (a source's first-ever tick) would violate the constraint. This mirrors the ordering already present in the current `runFetch` implementation. +9. **Reuse `preVector` from step 5 — do not call the embedder again.** For each source in the per-source map, `insertEmbedding(db, notificationId, preVector)`. This is exactly one Titan call per tick, not one per source: the text being embedded (`preMessage`) is identical across all of a tick's rows, so a second call would produce an identical vector at the cost of an extra API call. If step 5's embed call failed, `preVector` doesn't exist and this step is skipped entirely for the tick — no corpus row is written, `errors` already records why (from step 5). Per-source *store* failures (the `insertEmbedding` call itself throwing, e.g. a SQLite constraint issue) are caught individually and folded into `errors`; the notification row stays. 10. Finish the `agent_runs` row with `notificationsSent: 1` (one combined post per tick, regardless of how many sources contributed). 11. Publish the snapshot (unchanged). -The `runFetch` signature gains `weatherLocation: string` (so the writer can populate the context's `location` field). The result shape `RunFetchResult` is unchanged; `notificationsSent` is now `0 | 1` per tick. +The `runFetch` signature gains `weatherLocation: string` (so the writer can populate the context's `location` field). `src/localFetch.ts` is updated to pass `config.weatherLocation` through. The result shape `RunFetchResult` is unchanged; `notificationsSent` is now `0 | 1` per tick. ### 4.5 `src/rag/similarity.ts` — global KNN, returns `baseMessage` -`findNearestMatch(db, queryVector)` — the function loses its `source` parameter. The SQL removes the `source = ?` filter; the match is the single most similar notification across all sources. The return shape changes from `{ notificationId, distance, formattedMessage, postedAt }` to `{ notificationId, distance, baseMessage, postedAt }` — the match returns the past tick's `base_message` (the LLM's pre-suffix output), not its `formatted_message`. The writer uses `baseMessage` for the "Reminds me of" suffix, which is what prevents the snowball. +`findNearestMatch(db, queryVector)` — the function loses its `source` parameter. The SQL removes the `source = ?` filter and adds `WHERE n.base_message IS NOT NULL` (see below). The return shape changes from `{ notificationId, distance, formattedMessage, postedAt }` to `{ notificationId, distance, baseMessage, postedAt }` — the match returns the past tick's `base_message` (the LLM's pre-suffix output), not its `formatted_message`. The writer uses `baseMessage` for the "Reminds me of" suffix, which is what prevents the snowball. -The function is now called once per tick (in step 5 above), not once per source. The KNN is global; the per-source labels in `agent_embeddings` are no longer used for filtering (they remain as the `notification_id → source` join path for the status endpoint, unchanged). +**`base_message IS NOT NULL` is required, not optional.** The `base_message` migration (§3) leaves every pre-loop-feature row at `base_message = NULL`. Those rows are still valid KNN candidates in `agent_embeddings` (nothing about the vector table changes), so without this filter the join can return a row whose `baseMessage` is `null`, and step 6 in §4.4 would literally post `"...\n\nReminds me of: null"` to Discord. The filter is applied in application SQL on the joined `agent_notifications` row, not inside the `vec0` MATCH clause — `sqlite-vec`'s `k` parameter is evaluated on the raw vector scan, so the filter is a *post*-filter on the top-`k` candidates (same pattern as the existing `source` filter it replaces). One consequence worth documenting for a reader trying this locally: for the first `KNN_CANDIDATES` ticks after deploying this feature onto an existing snapshot, the entire top-`k` scan can consist of legacy `base_message IS NULL` rows, in which case the filter correctly yields no match and no suffix — this self-resolves as the corpus fills with post-migration rows. -### 4.6 `src/handler.ts` — new ops, EventBridge client +**No age floor — matching a few-minutes-old past tick is fine.** This tutorial is meant to be run and watched for 5–10 minutes at a time (per the loop's purpose in §1), so the suffix should be able to draw on a reading from a few minutes ago, not only on readings from hours or days earlier. The corpus already can't contain a match that's "too fresh" in the one sense that would actually be wrong: this tick's own message. `insertEmbedding` (step 9) runs after the RAG lookup (step 5) and after the post (step 7) within the same tick, so at query time the corpus contains only notifications that a *prior* tick already posted to Discord — the current tick's own `preMessage` is simply not in `agent_embeddings` yet when `findNearestMatch` runs. No timestamp filter is needed to enforce that; it falls out of the step ordering. -- Adds an `EventBridgeClient` to the `InjectedClients` interface (with a default constructed from `config.region`), constructed once per invocation like the existing `S3Client` and `BedrockRuntimeClient`. -- `op === 'loop-start'`: token check → `EnableRuleCommand({ Name: config.loopRuleName })` → return `{ loopState: 'ENABLED' }`. On error, return `{ statusCode: 500, body: JSON.stringify({ error: ... }) }`. -- `op === 'loop-stop'`: token check → `DisableRuleCommand({ Name: config.loopRuleName })` → return `{ loopState: 'DISABLED' }`. Same error shape. -- The `status` op is unchanged (no new fields). +The function is now called once per tick (in step 5 of §4.4 above), not once per source. The KNN is global; the per-source labels in `agent_embeddings` are no longer used for filtering (they remain as the `notification_id → source` join path for the status endpoint, unchanged). -### 4.7 `infra/stack.ts` — schedule, env, IAM, output, timeout +`KNN_CANDIDATES = 50`'s existing doc comment ("~7 weeks across two sources" for the old once-daily, per-source-filtered query) is stale under this design and must be rewritten: at 5-minute cadence, 2 sources, and 1 embedding row per source per tick, 50 candidates is roughly 2 hours of wall-clock history. That's a generous window for a short test run (the loop is expected to run for minutes, not hours — §1), so the ceiling is unlikely to bind in practice, but the comment should state the new math rather than the stale once-daily figure. -- Set `ruleName: 'sqlite-s3-agent-tutorial-fetch-loop'` on the `FetchSchedule` rule. -- Change `schedule: events.Schedule.rate(cdk.Duration.days(1))` to `events.Schedule.rate(cdk.Duration.minutes(3))`. -- Add `LOOP_RULE_NAME: rule.ruleName` and `LOOP_TOKEN: randomBytes(24).toString('hex')` to the Lambda environment. The token is generated at synth time and stays stable for the life of the stack (a redeploy regenerates it). -- Add a new IAM `PolicyStatement`: `events:EnableRule`, `events:DisableRule` on `rule.ruleArn`. The `retryAttempts: 0` and `RuleTargetInput.fromObject({ op: 'fetch' })` already in place stay. -- Add two new `CfnOutput`: `LoopToken` (the token) and `LoopRuleName` (the rule's name). The token output is what the start/stop scripts read. -- Bump `timeout: cdk.Duration.seconds(30)` to `cdk.Duration.seconds(60)` to give the two-step RAG flow (1 Converse + 2 Titan + S3 + Discord) comfortable headroom against transient Bedrock latency. (A 5-minute cadence leaves plenty of slack between ticks even with a 60s timeout; the 60s is a one-line safety bump.) +### 4.6 `src/handler.ts` — unchanged + +No changes. Loop start/stop never reaches the Lambda (§2), so there are no new ops, no `EventBridgeClient` in `InjectedClients`, and no handler-level token check to get right or wrong. + +### 4.7 `infra/stack.ts` — schedule, output, timeout + +- Change `schedule: events.Schedule.rate(cdk.Duration.days(1))` to `events.Schedule.rate(cdk.Duration.minutes(5))` on the `FetchSchedule` rule. The rule keeps its CDK-autogenerated name — no explicit `ruleName` prop. +- Add one new `CfnOutput`: `LoopRuleName` (`rule.ruleName`). This is the only new output; there is no token output because there is no token. +- Bump `timeout: cdk.Duration.seconds(30)` to `cdk.Duration.seconds(60)` to give the two-step RAG flow (1 Converse + 1 Titan + S3 + Discord) comfortable headroom against transient Bedrock latency. (A 5-minute cadence leaves plenty of slack between ticks even with a 60s timeout; the 60s is a one-line safety bump.) +- No IAM changes and no Lambda environment changes — this feature adds no permissions to the Lambda's role. +- **Note the redeploy interaction explicitly here** (carried from §1): because the rule is declared `enabled: true`, running `npm run deploy` after a `loop-stop.sh` re-enables the rule. This is inherent to declarative IaC managing a resource an imperative script also mutates, and is called out in the README rather than "fixed," since fixing it (e.g. reading current state at synth time) would add real complexity for a single-user tutorial. ### 4.8 `scripts/loop-start.sh` / `scripts/loop-stop.sh` -Two new scripts matching the style of `scripts/smoke.sh`. Both read `FUNCTION_URL` and `LOOP_TOKEN` from the stack's CloudFormation outputs (same `aws cloudformation describe-stacks` + `jq` pattern as `smoke.sh` reads `AgentFunctionName` and `AgentFunctionUrl`), then curl the right op. The user just runs `./scripts/loop-stop.sh` — no env vars to set, no token to copy, no auth to configure beyond the AWS CLI credentials the smoke script already needs. +Two new scripts matching the style of `scripts/smoke.sh`. Both read `LoopRuleName` from the stack's CloudFormation outputs (same `aws cloudformation describe-stacks` + `jq` pattern `smoke.sh` uses for `AgentFunctionName`/`AgentFunctionUrl`), then call the EventBridge API directly: + +```bash +# loop-stop.sh (loop-start.sh is the mirror image with enable-rule) +RULE_NAME=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='LoopRuleName'].OutputValue" --output text \ + --profile "$PROFILE" --region "$REGION") +aws events disable-rule --name "$RULE_NAME" --profile "$PROFILE" --region "$REGION" +STATE=$(aws events describe-rule --name "$RULE_NAME" --query State --output text \ + --profile "$PROFILE" --region "$REGION") +echo "Rule $RULE_NAME is now: $STATE" +``` -The scripts also print the rule's current state on success so the user can see the toggle took effect. +No Lambda invocation, no curl, no token, no `jq`-parsed HTTP response — this is a strictly simpler script than `smoke.sh`. The user just runs `./scripts/loop-stop.sh`; the only prerequisite is the AWS CLI credentials the smoke script already requires. --- @@ -209,29 +234,29 @@ The scripts also print the rule's current state on success so the user can see t | Scenario | Before | After | |---|---|---| -| Daily fetch at 1 day | One fetch per day, dedup on | Schedule changed to 5 min, no dedup. Same `op:"fetch"`, same Lambda. | -| Loop at 5 min | n/a | One Discord message per tick: date, location, weather, crypto, haiku, optional `\n\nReminds me of: ` suffix. | -| Message content | "Weather update: 72F" / "Crypto update: 67234.10" (one per source) | One message per tick: LLM-generated friendly comment + haiku + mechanical "Reminds me of" suffix from the global RAG match. | +| Fetch schedule | Once a day, dedup on | Every 5 minutes, no dedup. Same `op:"fetch"`, same Lambda. | +| Message content | "Weather update: 72F" / "Crypto update: 67234.10" (one message per source) | One combined message per tick: LLM-generated friendly comment drawing on all readings + haiku + mechanical "Reminds me of" suffix from the global RAG match. | | Dedup on value change | Always on | Removed. Writer always posts. | -| RAG query | Per-source, on raw value, against formatted-message corpus | Global, on LLM's pre-suffix output, against pre-suffix-output corpus. No per-source filter. | -| Stop the loop | n/a | `./scripts/loop-stop.sh`. EventBridge rule state = DISABLED. No further scheduled invocations. | -| Start the loop | n/a | `./scripts/loop-start.sh`. Rule state = ENABLED. Subsequent ticks post as normal. | -| Status endpoint | snapshotVersion, sources, recentNotifications | Unchanged (still shows the full posted message per `agent_notifications` row, including the "Reminds me of" suffix). | -| Loop token | n/a | Auto-generated by CDK at synth, exposed as `LoopToken` stack output. Scripts read it; user never copies a secret. | -| Lambda timeout | 30s | 60s (haiku generation + 2 Titan calls + S3 + Discord, with headroom). | +| RAG query | Per-source, on raw value, against formatted-message corpus | Global (no per-source filter), on the LLM's pre-suffix output, against a pre-suffix-output corpus, filtered to non-null `base_message`. No age floor — a match from a few minutes ago is valid (§4.5). | +| Stop the loop | n/a | `./scripts/loop-stop.sh` calls `aws events disable-rule` directly. No further scheduled invocations. Note: a subsequent `npm run deploy` re-enables the rule (§4.7). | +| Start the loop | n/a | `./scripts/loop-start.sh` calls `aws events enable-rule` directly. Subsequent ticks post as normal. | +| Status endpoint | snapshotVersion, sources, recentNotifications | Unchanged — no new fields, no code changes to `src/handler.ts` or `src/agent/status.ts`. | +| Loop control plane | n/a | Pure AWS CLI in the scripts (`aws events enable-rule`/`disable-rule`), reading the rule name from the `LoopRuleName` stack output. No Lambda op, no token, no new IAM grant. | +| Lambda timeout | 30s | 60s (haiku generation + 1 Titan call + S3 + Discord, with headroom). | --- ## 6. Error handling -- A `loop-start` / `loop-stop` op that fails the EventBridge API call returns 500 with the error message. The DB snapshot is not touched (EventBridge is the source of truth, not the snapshot). -- A `runFetch` invocation that fails because the EventBridge call disabled the rule is not a concern — `runFetch` is invoked *by* EventBridge, not the other way around. The `events:DisableRule` call simply means EventBridge stops calling `runFetch`. +- A `loop-start.sh` / `loop-stop.sh` invocation that fails the `aws events enable-rule`/`disable-rule` call fails with the AWS CLI's own non-zero exit code and error output (e.g. missing IAM permission, rule not found). There is no application-level error shape to design here — the CLI call either succeeds or the script surfaces the CLI's own failure. The SQLite snapshot is not touched by these scripts (EventBridge is the source of truth, not the snapshot). +- A `runFetch` invocation that would have run because the EventBridge rule was disabled mid-tick is not a concern — `runFetch` is invoked *by* EventBridge, not the other way around. `disable-rule` simply means EventBridge stops calling `runFetch`; any invocation already in flight when the rule is disabled completes normally. +- **A redeploy re-enables a stopped loop.** `npm run deploy` after `loop-stop.sh` resets the rule to `enabled: true` (§4.7) — not a script or handler error, but a real gotcha for a reader who redeploys for an unrelated reason and is surprised the loop (and its Bedrock cost) resumed. Documented in the README (§8), not silently "fixed." - A per-source fetch failure in step 2 is caught and folded into `errors`. The other sources still contribute to the combined message. `agent_sources` is not updated for the failed source. - If all sources fail (step 3), the tick is a no-op for the rest of the flow. `notificationsSent: 0`. The snapshot is still published (it carries the `agent_runs` row recording the failure). - A formatter error in step 4 is caught and folded into `errors`. The RAG lookup, post, and per-source inserts are skipped for this tick. `notificationsSent: 0`. -- A RAG embed or lookup error in step 5 is caught and folded into `errors`. The flow proceeds with no suffix. The LLM's output is still posted; no "Reminds me of" line is appended. The closest-match columns on `agent_notifications` are null for this tick. -- A post error in step 7 is caught and folded into `errors`. The per-source inserts are skipped for this tick. `notificationsSent: 0`. -- A per-source embed/store failure in step 9 is caught and folded into `errors`. The notification row stays; only the RAG corpus fails to grow by this entry. +- A RAG embed or lookup error in step 5 is caught and folded into `errors`. The flow proceeds with no suffix, and `preVector` is treated as unset — step 9's embedding insert is skipped for this tick (there is nothing to reuse; a second Titan call is never made to compensate). The LLM's output is still posted; no "Reminds me of" line is appended. The closest-match columns on `agent_notifications` are null for this tick. +- A post error in step 7 is caught and folded into `errors`. The per-source inserts (step 8) and the embedding insert (step 9) are skipped for this tick. `notificationsSent: 0`. +- A per-source `insertEmbedding` failure in step 9 (e.g. a SQLite constraint issue on that specific row) is caught individually and folded into `errors`. The notification row stays; only that source's RAG corpus entry fails to be written. This is distinct from the step 5 embed failure above — step 5 failing skips step 9 for *all* sources this tick (no vector exists to insert), while a step 9 failure is isolated to the one row that failed. - A Discord 4xx is not retried (existing behavior); a 5xx gets one ~250ms retry (existing). Same rules apply. --- @@ -240,22 +265,24 @@ The scripts also print the rule's current state on success so the user can see t Unit tests (vitest) for: -- `runFetch` happy path with RAG history: both sources succeed, formatter is called ONCE with a clean `LoopContext` (no RAG fields), the LLM's output is embedded, KNN returns a match whose `baseMessage` is the past tick's pre-suffix text, `finalMessage` includes the `\n\nReminds me of: ` suffix, Discord is posted ONCE, two `agent_notifications` rows are written (one per source) with the same `formatted_message` (including the suffix), the same `base_message` (the pre-suffix text), and the same `nearest_match_id`, two `agent_embeddings` rows are inserted keyed on `base_message`. +- `runFetch` happy path with RAG history: both sources succeed, formatter is called ONCE with a clean `LoopContext` (`readings` array, no RAG fields), the LLM's output is embedded ONCE, KNN returns a match whose `baseMessage` is the past tick's pre-suffix text, `finalMessage` includes the `\n\nReminds me of: ` suffix, Discord is posted ONCE, `agent_sources` is upserted before `agent_notifications` is inserted (assert insert order or assert the FK would otherwise fail on a brand-new source — see the fresh-source case below), two `agent_notifications` rows are written (one per source) with the same `formatted_message` (including the suffix), the same `base_message` (the pre-suffix text), and the same `nearest_match_id`, two `agent_embeddings` rows are inserted keyed on `base_message`, and the embedder's `embed()` is asserted to have been called exactly once for the whole tick (not once per source). - `runFetch` first-tick path (no RAG history): the LLM is called, the RAG lookup returns null (no past notifications), `finalMessage = preMessage` (no suffix), one combined Discord post, two notification rows with `formatted_message = base_message = preMessage` and `nearest_match_id = null`. -- **Snowball regression test (critical):** simulate 20 consecutive ticks where each past `formatted_message` is built by appending the prior tick's full `formatted_message` to a ~150-char base. Without the `base_message` separation, the 20th tick's `finalMessage` would exceed Discord's 2000-char limit and the test would assert a 400-style post failure. With the separation, each tick's `base_message` is always ~150 chars and the `finalMessage` is always bounded (base + one suffix of past base). The test pins this: 20 ticks, every post succeeds, every `finalMessage.length < 500`. -- `runFetch` with one source failing (e.g., coingecko down): the other source still contributes; formatter receives `weatherValue: '72F'`, `cryptoValue: ''`; one notification row per successful source; `errors` includes the failed source. -- `runFetch` with both sources failing: formatter is not called, no Discord post, no notification rows, `errors` includes both sources, `notificationsSent: 0`. +- `runFetch` with a brand-new source (no prior `agent_sources` row): the `agent_sources` upsert must run before the `agent_notifications` insert for that source, or the insert throws a foreign-key constraint violation. Test asserts no FK error and that both tables end up populated correctly — this is a regression test for the corrected step-8 ordering (§4.4). +- `runFetch` legacy-corpus RAG match: seed `agent_embeddings` with rows whose joined `agent_notifications.base_message IS NULL` (simulating pre-migration data) as the closest vectors, plus one older row with a non-null `base_message`. Assert `findNearestMatch` skips the null rows and returns the valid one — and, in isolation, assert that with *only* null-`base_message` candidates present, the match is `null` and `finalMessage` never contains the literal string `"null"`. +- `runFetch` matches a recent past tick: seed the corpus with a notification posted a few minutes before the current tick (no age gap engineered in) and assert `findNearestMatch` returns it — a fresh-but-already-posted match is a valid match, not filtered out. This pins the "no age floor" decision (§4.5) as a regression test, since the natural next "fix" someone might reach for is re-adding a time filter. +- `runFetch` never matches its own tick: within a single `runFetch` call, assert `findNearestMatch` (step 5) cannot return the notification ID this same tick is about to write (step 8) — verified by asserting the RAG lookup runs, and its result set, before any insert from this tick exists in `agent_embeddings`. This is a regression test for the step ordering in §4.4 (lookup before insert) that makes an explicit "too fresh" filter unnecessary. +- **Snowball regression test (critical):** drive `runFetch` through 20 consecutive ticks in quick succession (no time gap needed between ticks, since there's no age floor to clear), with a stub formatter/embedder returning a fixed ~150-char `preMessage` each tick so each tick's RAG match is genuinely available from the very first repeat. Assert: `poster.post` is called 20 times, every call succeeds, and every posted string has `length < 500`. This drives the actual `runFetch` → `findNearestMatch` → suffix-building path rather than asserting against a hypothetical alternate implementation that concatenates `formatted_message` — the real code has no path that could reproduce a snowball, and this test would fail immediately if someone later wired the suffix to `match.formattedMessage` instead of `match.baseMessage`. +- `runFetch` with one source failing (e.g., coingecko down): the other source still contributes; the `LoopContext.readings` array formatter receives contains only the successful source's entry (no `''` placeholder — see §4.2); one notification row per successful source; `errors` includes the failed source. +- `runFetch` with all sources failing: formatter is not called, no Discord post, no notification rows, `errors` includes every source, `notificationsSent: 0`. - `runFetch` formatter failure: caught and folded into `errors`, no RAG lookup, no Discord post, no notification rows, snapshot is still published. -- `runFetch` RAG lookup failure (Titan embed throws): caught and folded into `errors`, `finalMessage = preMessage` (no suffix), post still happens, `nearest_match_id = null` on the rows. +- `runFetch` RAG lookup failure (Titan embed throws): caught and folded into `errors`, `finalMessage = preMessage` (no suffix), post still happens, `nearest_match_id = null` on the rows, and `insertEmbedding` is never called for this tick (no `preVector` to reuse) — assert the embedder's `embed()` was called exactly once total (the failed call), not a second time as a fallback. - `runFetch` post failure: caught and folded into `errors`, no per-source notification rows, no embeddings, snapshot is still published. -- `loop-start` op with valid `LOOP_TOKEN`: calls `EventBridge.EnableRuleCommand` with `LOOP_RULE_NAME`, returns 200 `{loopState: 'ENABLED'}`. -- `loop-stop` op with valid `LOOP_TOKEN`: calls `EventBridge.DisableRuleCommand`, returns 200 `{loopState: 'DISABLED'}`. -- `loop-start` / `loop-stop` without `LOOP_TOKEN`: 403, no EventBridge call. -- Formatter receives the new `LoopContext` shape and the new fields are present in the Bedrock request body (via the existing `aws-sdk-client-mock` ConverseCommand matcher). +- Formatter receives the new `LoopContext` shape (`date`, `location`, `readings: [{source, value}]`) and the new fields are present in the Bedrock request body (via the existing `aws-sdk-client-mock` ConverseCommand matcher). - `findNearestMatch` with no per-source filter: returns the closest notification across all sources, not the closest within a single source. Existing RAG tests that exercised per-source filtering are updated. -- Existing handler tests: the `sourceOverrides` shape changes (each `SourceFetcher` is still replaced with a `() => Promise`, but the test now asserts ONE formatter call instead of N per-source calls). +- Existing handler tests: the `sourceOverrides` shape changes (each `SourceFetcher` is still replaced with a `() => Promise`, but the test now asserts ONE formatter call instead of N per-source calls). No new handler tests are needed for loop start/stop — there is no handler code for it (§4.6). +- `src/localFetch.ts` passes `config.weatherLocation` through to `runFetch` (compile-time check via the existing local-fetch smoke test, if one exists, or a type-level assertion). -No tests for the RAG corpus-bloat rate (would require running the loop for hours to measure); called out as a known characteristic in the budget note, not a testable invariant. +No tests for the RAG corpus-bloat rate (would require running the loop for hours to measure); called out as a known characteristic in the budget note, not a testable invariant. No tests for `loop-start.sh` / `loop-stop.sh` — they are thin wrappers around `aws events enable-rule`/`disable-rule` with no application logic, matching the existing lack of automated tests for `scripts/smoke.sh`. --- @@ -263,15 +290,15 @@ No tests for the RAG corpus-bloat rate (would require running the loop for hours Per the user instruction, no new `docs/0X-*.md` tutorial file. Two small changes: -- **`README.md`** — new "Loop mode" subsection under "Quick start" with the `./scripts/loop-start.sh` / `./scripts/loop-stop.sh` commands, a one-liner explaining the loop and the "Reminds me of" feature, and a clear "stop the loop when you're done — `loop-stop` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops." -- **`docs/07-budget-protection.md`** — one paragraph added: with the loop running at 5-min cadence, expect ~864 Bedrock calls/day (1 Converse + 2 Titan per tick × 288 ticks). At default model pricing, this is roughly $0.03–$0.06/day. The RAG corpus (`agent_notifications` + `agent_embeddings`) grows by ~576 rows/day while running; short-lived loops are fine, but `loop-stop` is the way to actually shut down the recurring cost. +- **`README.md`** — new "Loop mode" subsection under "Quick start" with the `./scripts/loop-start.sh` / `./scripts/loop-stop.sh` commands, a one-liner explaining the loop and the "Reminds me of" feature, an explicit note that both scripts call the EventBridge API directly (`aws events enable-rule`/`disable-rule`) — no Lambda involved, no token to manage — and a clear warning: "stop the loop when you're done — `loop-stop.sh` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops. Note: running `npm run deploy` after `loop-stop.sh` re-enables the rule, since the CDK stack declares it `enabled: true` — re-run `loop-stop.sh` after any redeploy if you want the loop to stay off." +- **`docs/07-budget-protection.md`** — one paragraph added: with the loop running at 5-min cadence, expect ~576 Bedrock calls/day (1 Converse + 1 Titan per tick × 288 ticks/day). At default model pricing, this is roughly $0.02–$0.04/day. `agent_notifications` and `agent_embeddings` each grow by ~576 rows/day while running (~1,152 rows/day combined); short-lived loops are fine, but `loop-stop.sh` is the way to actually shut down the recurring cost. --- ## 9. Open concerns (out of scope for this spec) - **RAG corpus bloat over long runs.** If the loop is ever left running for days, `agent_notifications` and `agent_embeddings` grow linearly (~576 rows/day each at 5-min cadence with two sources). A future spec could add a sliding-window cap or a vacuum job. This spec keeps the "every posted message is searchable forever" RAG invariant intact and relies on the short-lived nature of the loop in practice. -- **Loop token rotation.** `LOOP_TOKEN` is auto-generated at synth time and stays stable for the life of the stack. A redeploy regenerates the token and updates the stack output. There's no manual override path — for a single-user tutorial this is the deliberate trade-off for not managing a secret by hand. If the token is leaked, the recovery is `npm run deploy` (which writes a new token). +- **No RAG age floor is a deliberate choice for a short-lived, watched loop.** An earlier draft of this spec added a 1-hour minimum age on RAG matches to avoid the "Reminds me of: <5-minutes-ago message>" echo reading as a stutter. That trades against the loop's actual purpose (§1): the tutorial is meant to be started, watched for 5–10 minutes, and stopped, and a 1-hour floor would mean most readers never see the "Reminds me of" feature fire at all during a normal test run. This spec accepts the near-echo as the expected behavior at short cadence — a match from a few minutes ago is still a genuine "the corpus found something," which is the point being demonstrated. A future spec revisiting this for a longer-running or production-shaped deployment could reintroduce an age floor or a similarity-distance threshold; out of scope here. - **Per-source RAG metadata on the row.** With the new design, the `nearest_match_id` / `nearest_match_distance` columns are the same on all per-source rows of a tick (the single global RAG match for the combined message). The status endpoint will show the same match twice (once per source row). This is mildly redundant but not wrong; if a future spec wants to deduplicate the per-source rows into one row per tick, that's a schema change worth its own design. - **"Reminds me of" message length.** With the `base_message` separation, the suffix is always built from the past tick's pre-suffix text (a single LLM output, ~150 chars), never from a past `formatted_message` that includes a prior "Reminds me of" line. The posted message is bounded at one base + one suffix (~300 chars typical), well under Discord's 2000-char limit. No chain growth, no truncation needed. -- **CDK synth-time token across multi-env deploys.** `crypto.randomBytes(24)` runs at synth time, so the same `cdk.out` template deployed to multiple environments carries the same token. For a single-user tutorial this is acceptable; the documented behavior is "redeploy to rotate." +- **Rule-state drift between EventBridge and CDK is inherent, not fixed.** Because start/stop is now a pure imperative AWS CLI call against a resource CDK also declaratively manages (§4.7), the rule's actual state can diverge from what's in the CDK template between deploys. This spec accepts that drift and documents the one concrete consequence (redeploy re-enables a stopped loop) rather than adding synth-time or runtime logic to reconcile it — doing so (e.g. a custom resource that preserves state across deploys) would add meaningfully more infrastructure for a single-user tutorial's convenience feature. From 2dccc003ed2658e5bcb4e9244d271b831e2ac554 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:33:29 -0400 Subject: [PATCH 24/26] fix(pr): address CodeRabbit review comments - Enforce Discord 2000-char limit in fetch.ts (post: buildFinalMessageForDiscord helper; preserves preMessage, clips suffix with '...' when it doesn't fit, drops the suffix when there's not even room for a clip marker). Adds six unit tests covering the boundary cases. - README: align Loop mode opening with the 5-min default schedule; clarify loop-start is only needed after loop-stop. - docs/07-budget-protection.md: 'three' -> 'four' for cost-driver count. - tests/db.test.ts: assert the legacy row survives the second bootstrap (data preservation, not only schema shape). - tests/format.test.ts: annotate the ctx helper return value as LoopContext. - tests/handler.test.ts: stub InvokeModelCommand (Titan) and global fetch (Discord) in beforeEach; the two-source test now asserts both outbound calls happen, so runFetch cannot silently pass after a swallowed failure. - tests/similarity.test.ts: pin KNN-window behavior for legacy rows (51 null rows + 1 far valid row -> findNearestMatch returns null). - tests/fetch.test.ts: correct the 'epoch date' comment to reflect that now: () => 2000 is 2000ms after the epoch. Skipped: CodeRabbit suggested adding loop-start/loop-stop operations to runHandler. The scripts call 'aws events enable-rule' / 'aws events disable-rule' directly and never invoke the Lambda (see PR body), so the HTTP-path addition would not be wired to anything and would add a token / IAM surface the spec deliberately omits. --- README.md | 8 ++--- docs/07-budget-protection.md | 4 +-- src/agent/fetch.ts | 60 ++++++++++++++++++++++++++++++++++-- tests/db.test.ts | 8 +++++ tests/fetch.test.ts | 57 +++++++++++++++++++++++++++++++++- tests/format.test.ts | 7 ++++- tests/handler.test.ts | 33 ++++++++++++++++++-- tests/similarity.test.ts | 21 +++++++++++++ 8 files changed, 185 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3b86352..17dfffe 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ what breaks if you skip it. ## Loop mode -For local testing and quick iteration, the agent can run a 5-minute loop instead of -the once-daily schedule. After the same `npm run deploy` as for the daily schedule, -toggle the loop on and off directly from your shell — no Lambda invocation, no token, -no extra IAM grants: +The deployed EventBridge schedule runs every 5 minutes by default. Use `loop-stop` to +pause the loop, and re-run `loop-start` only after `loop-stop` to resume it. Both +scripts toggle the EventBridge rule directly from your shell — no Lambda invocation, +no token, no extra IAM grants: ```bash npm run loop-start # calls `aws events enable-rule` on the deployed rule diff --git a/docs/07-budget-protection.md b/docs/07-budget-protection.md index b5c1e70..308f00c 100644 --- a/docs/07-budget-protection.md +++ b/docs/07-budget-protection.md @@ -1,7 +1,7 @@ # Budget protection This tutorial's normal cost is near zero — see [the README's Cost section](../README.md#cost). -But three things can push it above "near zero" if misconfigured, and none of them are +But four things can push it above "near zero" if misconfigured, and none of them are caught by anything else in this repo: they're all *valid* requests that just happen more often than intended. @@ -35,7 +35,7 @@ error, and the right backstop for operator error is a spending alarm, not more c ## Set up an AWS Budget -A cost budget with an email (or SNS) alert takes a few minutes and catches all three cases +A cost budget with an email (or SNS) alert takes a few minutes and catches all four cases above, since all of them show up as spend regardless of which one caused it. Console: **Billing and Cost Management → Budgets → Create budget → Cost budget**. Suggested diff --git a/src/agent/fetch.ts b/src/agent/fetch.ts index 6ca085d..9587dc1 100644 --- a/src/agent/fetch.ts +++ b/src/agent/fetch.ts @@ -34,6 +34,55 @@ export interface RunFetchResult { error: string | null; } +/** Discord's hard cap on webhook message content. A webhook POST with a `content` + * field over this length is rejected with a 400 before notification/embedding + * persistence — so the writer must bound the final message here, not at the poster. + * See https://docs.discord.com/developers/resources/webhook#execute-webhook. */ +export const DISCORD_MAX_MESSAGE_CHARS = 2000; + +const REMINDS_ME_OF_SEPARATOR = '\n\nReminds me of: '; +const SUFFIX_TRUNCATION_MARKER = '...'; + +/** + * Builds the message posted to Discord from the LLM's pre-suffix output and the + * optional RAG match. Enforces Discord's 2000-character hard cap so a malformed + * LLM output or an unexpectedly long past `base_message` cannot cause Discord to + * reject the post before the notification/embedding persistence step. + * + * Rules: + * - If `preMessage` is longer than the cap on its own, truncate it and return + * (no suffix — the preMessage cannot be preserved once it doesn't fit). + * - If the full suffix (`"\n\nReminds me of: "`) fits, use it whole. + * - If the suffix does not fit but there is room for at least a clipped version, + * truncate `baseMessage` to fit and append `...` as a clip marker. + * - If there is not even room for the separator, omit the suffix entirely. + */ +export function buildFinalMessageForDiscord( + preMessage: string, + baseMessage: string | null, + limit: number = DISCORD_MAX_MESSAGE_CHARS, +): string { + if (preMessage.length > limit) { + return preMessage.slice(0, limit); + } + if (baseMessage === null) { + return preMessage; + } + const fullSuffix = REMINDS_ME_OF_SEPARATOR + baseMessage; + if (preMessage.length + fullSuffix.length <= limit) { + return preMessage + fullSuffix; + } + // Need to clip the suffix. Room available for the entire suffix line. + const room = limit - preMessage.length; + // No room for even the separator + clip marker — drop the suffix entirely. + if (room < REMINDS_ME_OF_SEPARATOR.length + SUFFIX_TRUNCATION_MARKER.length) { + return preMessage; + } + const clippedBase = + room - REMINDS_ME_OF_SEPARATOR.length - SUFFIX_TRUNCATION_MARKER.length; + return preMessage + REMINDS_ME_OF_SEPARATOR + baseMessage.slice(0, clippedBase) + SUFFIX_TRUNCATION_MARKER; +} + /** * The writer op (spec §3.1, loop-mode + poetic-closing spec §4.4). Hydrates from the * store, runs bootstrap, fetches every source, formats ONCE with a `LoopContext`, @@ -166,9 +215,14 @@ export async function runFetch(params: RunFetchParams): Promise } // Step 9: build the final message. The suffix is built from past base_message - // (never past formatted_message), so the chain cannot snowball. - const finalMessage = - match !== null ? `${preMessage}\n\nReminds me of: ${match.baseMessage}` : preMessage; + // (never past formatted_message), so the chain cannot snowball. The result is + // bounded to Discord's 2000-character webhook cap via `buildFinalMessageForDiscord` + // — without that guard, an oversized preMessage or past baseMessage would cause + // Discord to reject the post before this tick's notification/embedding writes. + const finalMessage = buildFinalMessageForDiscord( + preMessage, + match === null ? null : match.baseMessage, + ); // Step 10: post once. try { diff --git a/tests/db.test.ts b/tests/db.test.ts index 0a2d759..8e6700c 100644 --- a/tests/db.test.ts +++ b/tests/db.test.ts @@ -239,6 +239,14 @@ describe('bootstrap — nearest_match columns', () => { const colsAfter = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; expect(colsAfter.filter((c) => c.name === 'base_message')).toHaveLength(1); + // The legacy row (inserted with base_message = NULL above) must survive the + // second bootstrap unchanged — this pins data preservation, not only schema shape. + const legacy = db + .prepare(`SELECT base_message FROM agent_notifications`) + .all() as Array<{ base_message: string | null }>; + expect(legacy).toHaveLength(1); + expect(legacy[0]?.base_message).toBeNull(); + db.close(); rmSync(dir, { recursive: true, force: true }); }); diff --git a/tests/fetch.test.ts b/tests/fetch.test.ts index f117874..7833687 100644 --- a/tests/fetch.test.ts +++ b/tests/fetch.test.ts @@ -11,6 +11,7 @@ import { createLocalEmbedder } from '../src/embed/local.js'; import type { LoopContext } from '../src/format/types.js'; import type { MessageFormatter } from '../src/format/types.js'; import { runFetch } from '../src/agent/fetch.js'; +import { buildFinalMessageForDiscord } from '../src/agent/fetch.js'; import { fakeSourceFetcher, throwingSourceFetcher } from './helpers/fakeSourceFetcher.js'; import { fakeDiscordPoster } from './helpers/fakeDiscordPoster.js'; import type { Embedder } from '../src/embed/titan.js'; @@ -109,7 +110,7 @@ describe('runFetch', () => { // we pin the LoopContext shape against. expect(formatter.calls).toHaveLength(2); const r2Ctx = formatter.calls[1]; - expect(r2Ctx?.date).toBe('1970-01-01'); // epoch date — this stub doesn't override `now` + expect(r2Ctx?.date).toBe('1970-01-01'); // `now: () => 2000` is 2000ms after the epoch expect(r2Ctx?.location).toBe('Brooklyn'); expect(r2Ctx?.readings.map((r) => ({ source: r.source, value: r.value }))).toEqual([ { source: 'weather', value: '73F' }, @@ -631,3 +632,57 @@ describe('runFetch', () => { ctx.cleanup(); }); }); + +describe('buildFinalMessageForDiscord', () => { + it('returns preMessage unchanged when there is no RAG match', () => { + const result = buildFinalMessageForDiscord('hello world', null); + expect(result).toBe('hello world'); + }); + + it('appends the suffix verbatim when preMessage + suffix fits under 2000 chars', () => { + const preMessage = 'A short comment about today.'; + const baseMessage = 'A past comment from yesterday.'; + const result = buildFinalMessageForDiscord(preMessage, baseMessage); + expect(result).toBe(`${preMessage}\n\nReminds me of: ${baseMessage}`); + expect(result.length).toBeLessThanOrEqual(2000); + }); + + it('truncates the suffix with a clip marker when it would exceed 2000 chars', () => { + // preMessage consumes 1900 chars; suffix separator is 19 chars; baseMessage fills + // 200 chars, so the whole suffix is 219 chars and total would be 2119. + const preMessage = 'a'.repeat(1900); + const baseMessage = 'b'.repeat(200); + const result = buildFinalMessageForDiscord(preMessage, baseMessage); + expect(result.length).toBeLessThanOrEqual(2000); + expect(result.startsWith(preMessage)).toBe(true); + expect(result).toContain('Reminds me of: '); + expect(result.endsWith('...')).toBe(true); + // The baseMessage portion is clipped — not the whole 200 chars survive. + expect(result.length).toBeLessThan(preMessage.length + 19 + 200); + }); + + it('truncates preMessage itself when it alone exceeds 2000 chars, with no suffix', () => { + const preMessage = 'a'.repeat(3000); + const result = buildFinalMessageForDiscord(preMessage, 'a past message'); + expect(result.length).toBe(2000); + expect(result).not.toContain('Reminds me of'); + }); + + it('omits the suffix entirely when there is no room for even a clipped version', () => { + // preMessage leaves only 5 chars of headroom — separator (19) + clip marker (3) = 22, + // so the helper drops the suffix instead of producing a meaningless stub. + const preMessage = 'a'.repeat(1995); + const baseMessage = 'b'.repeat(200); + const result = buildFinalMessageForDiscord(preMessage, baseMessage); + expect(result).toBe(preMessage); + }); + + it('accepts a custom limit (used for the snowball regression test threshold of 500)', () => { + const preMessage = 'a'.repeat(400); + const baseMessage = 'b'.repeat(200); + const result = buildFinalMessageForDiscord(preMessage, baseMessage, 500); + expect(result.length).toBeLessThanOrEqual(500); + expect(result.startsWith(preMessage)).toBe(true); + expect(result.endsWith('...')).toBe(true); + }); +}); diff --git a/tests/format.test.ts b/tests/format.test.ts index 7c4a3d5..746cb6b 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -1,8 +1,13 @@ // tests/format.test.ts import { describe, expect, it } from 'vitest'; import { createLocalTemplateFormatter } from '../src/format/local.js'; +import type { LoopContext } from '../src/format/types.js'; -const ctx = (readings: Array<{ source: 'weather' | 'crypto'; value: string }>, date = '2026-08-09', location = 'NYC') => ({ +const ctx = ( + readings: Array<{ source: 'weather' | 'crypto'; value: string }>, + date = '2026-08-09', + location = 'NYC', +): LoopContext => ({ date, location, readings, diff --git a/tests/handler.test.ts b/tests/handler.test.ts index 3a8b5b5..dc041c8 100644 --- a/tests/handler.test.ts +++ b/tests/handler.test.ts @@ -5,12 +5,13 @@ import { PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; -import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime'; +import { BedrockRuntimeClient, ConverseCommand, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; +import { Uint8ArrayBlobAdapter } from '@smithy/core/serde'; import { mockClient } from 'aws-sdk-client-mock'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runHandler } from '../src/handler.js'; const s3 = mockClient(S3Client); @@ -18,15 +19,31 @@ const bedrock = mockClient(BedrockRuntimeClient); describe('runHandler', () => { let dir: string; + let fetchSpy: ReturnType; beforeEach(() => { s3.reset(); bedrock.reset(); dir = mkdtempSync(join(tmpdir(), 'agent-handler-test-')); + // Stub the Titan embed call (`InvokeModelCommand`) globally so the embedder gets + // a valid 256-dim vector. Without this, runFetch catches the embed failure and + // still returns success — a regression in the Titan path could pass through. + // The body must be a smithy `IUint8ArrayBlobAdapter` (a Uint8Array with a + // `transformToString` method); using a plain Uint8Array trips the SDK's strict + // input type. `Uint8ArrayBlobAdapter.fromString` produces a properly adapted one. + bedrock.on(InvokeModelCommand).resolves({ + body: Uint8ArrayBlobAdapter.fromString(JSON.stringify({ embedding: new Array(256).fill(0) })), + }); + // Stub the real fetch the DiscordPoster uses. Without this, the writer can perform + // an unstubbed outbound POST and still return 200 after runFetch catches the failure + // — which would let a regression in the embed or format path hide behind the + // swallowing catch. A 204 No Content is a valid Discord webhook success. + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); + fetchSpy.mockRestore(); }); it('routes op="fetch" through the writer and returns 200', async () => { @@ -256,6 +273,18 @@ describe('runHandler', () => { // The combined-message reformulation means exactly one formatter call per tick, // regardless of source count — the per-source loop is gone. expect(bedrock.commandCalls(ConverseCommand)).toHaveLength(1); + // The embed call also runs once per tick (not per source) — the test must pin + // this so a regression that fans the embed out per source fails loudly. Without + // this assertion, the InvokeModelCommand stub in beforeEach would silently absorb + // any number of calls and the test would still pass. + expect(bedrock.commandCalls(InvokeModelCommand).length).toBeGreaterThanOrEqual(1); + // And the Discord poster must actually post exactly once — without asserting this, + // a swallowed fetch failure (the real fetch is spied in beforeEach) would let + // runFetch catch the error and return success. + expect(fetchSpy).toHaveBeenCalledTimes(1); + const fetchArgs = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(fetchArgs[0]).toBe('https://discord.example/webhook'); + expect(JSON.parse(String(fetchArgs[1].body))).toMatchObject({ content: expect.any(String) }); }); }); }); diff --git a/tests/similarity.test.ts b/tests/similarity.test.ts index fd3f188..b47ef5d 100644 --- a/tests/similarity.test.ts +++ b/tests/similarity.test.ts @@ -124,6 +124,27 @@ describe('findNearestMatch', () => { cleanup(dir, db); }); + it('returns null when the KNN window is filled with NULL rows even if a valid row exists further away', () => { + // Pins the post-filter contract: the SQL `WHERE n.base_message IS NOT NULL` filter + // runs after sqlite-vec's top-k scan, so a valid row that's outside the top-k window + // must NOT be returned — only the null filter's outcome (null) is the answer. + // KNN_CANDIDATES is 50 (see src/rag/similarity.ts); the test inserts 51 null rows + // so the top-50 window is entirely null rows, with the valid row at rank 52. + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + for (let i = 0; i < 51; i += 1) { + const id = insertNotification(db, 'weather', `legacy ${i}`, 1000 + i, null); + insertEmbedding(db, id, unitVector(0)); // all 51 are tied at distance 0 from the query + } + + const valid = insertNotification(db, 'weather', 'valid posted', 900, 'valid base'); + insertEmbedding(db, valid, unitVector(50)); // far from unitVector(0), outside top-50 + + expect(findNearestMatch(db, unitVector(0))).toBeNull(); + cleanup(dir, db); + }); + it('matches a recent (few-minutes-old) past notification — no age floor', () => { const { dir, db } = setup(); db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); From 77906d97a876422f0c56c9ef93bab5b89d9b7416 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:42:14 -0400 Subject: [PATCH 25/26] fix(pr): address CodeRabbit round 2 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetch.ts: drop redundant db.close() before delegating to publish() in the formatter-failure and post-failure early-return paths — publish() owns the close. - fetch.ts: rewrite publish() JSDoc to describe the actual lifecycle (closes passed-in DB; reopens only on the error path) and document the caller invariant (caller must not close first). - bootstrap.ts: fix migration comment that referenced a 'LIKE exclusion' — the real filter is WHERE n.base_message IS NOT NULL. - handler.test.ts: tighten the InvokeModelCommand assertion to toHaveLength(1) so a per-source embed regression fails loudly. - docs/02-rehydration.md: clarify that ENOSPC happens on the local writeFileSync of the hydrated snapshot, not inside s3.GetObject. --- docs/02-rehydration.md | 7 ++++--- src/agent/fetch.ts | 11 ++++++----- src/db/bootstrap.ts | 4 ++-- tests/handler.test.ts | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/02-rehydration.md b/docs/02-rehydration.md index 36d08a5..86b11b1 100644 --- a/docs/02-rehydration.md +++ b/docs/02-rehydration.md @@ -77,9 +77,10 @@ invocations. That storage has a hard ceiling: A snapshot row plus its embedding is roughly 4 KB on disk. 512 MB holds about 131,000 ticks' worth of rows — enough that the file still fits in `/tmp` after about 450 days of running the 5-minute loop (`npm run loop-start`, 288 ticks/day — see the README's Loop -mode section). Past that, `s3.GetObject` fails with `No space left on device` on the -next hydrate and the writer publishes nothing until a redeploy resurfaces a fresh -container with an empty `/tmp`. +mode section). Past that, the writer's local `writeFileSync` of the hydrated snapshot +to `/tmp/memory.db` fails with `ENOSPC: no space left on device` (the S3 `GetObject` +itself succeeds — the bytes are in memory by then), and the writer publishes nothing +until a redeploy resurfaces a fresh container with an empty `/tmp`. If you intend to leave the loop running unattended for longer than that, set `ephemeralStorage: Size.gibibytes(10)` on `agentFunction` in `infra/stack.ts` and diff --git a/src/agent/fetch.ts b/src/agent/fetch.ts index 9587dc1..377eab1 100644 --- a/src/agent/fetch.ts +++ b/src/agent/fetch.ts @@ -196,7 +196,6 @@ export async function runFetch(params: RunFetchParams): Promise notificationsSent: 0, error: errors.join('; '), }); - db.close(); return publish(db, params, priorEtag, runId, now, 0, errors); } @@ -238,7 +237,6 @@ export async function runFetch(params: RunFetchParams): Promise notificationsSent: 0, error: errors.join('; '), }); - db.close(); return publish(db, params, priorEtag, runId, now, 0, errors); } @@ -328,9 +326,12 @@ export async function runFetch(params: RunFetchParams): Promise } /** - * Short-circuits the publish step for tick-level failures — reopens the DB, closes it, - * and returns the standard RunFetchResult. The early-return paths in `runFetch` use - * this so the conditional-publish logic only lives in one place. + * Short-circuits the publish step for tick-level failures — closes the passed-in DB, + * publishes the snapshot, and returns the standard RunFetchResult. On a publish + * failure it reopens the DB to record the run as failed before re-throwing. The + * early-return paths in `runFetch` use this so the conditional-publish logic only + * lives in one place. The caller must NOT close `db` before calling — this helper + * owns the close. */ function publish( db: Database.Database, diff --git a/src/db/bootstrap.ts b/src/db/bootstrap.ts index d404f5c..1d709a7 100644 --- a/src/db/bootstrap.ts +++ b/src/db/bootstrap.ts @@ -18,8 +18,8 @@ export function bootstrap(db: Database.Database): void { * pre-suffix output (the friendly comment + haiku). The RAG corpus embeds this * column, and `findNearestMatch` returns it for the "Reminds me of" suffix — * never the posted `formatted_message` — so the suffix cannot snowball. - * Nullable so legacy rows post-migration carry `NULL` and the LIKE exclusion - * in `findNearestMatch` keeps them out of match candidacy. + * Nullable so legacy rows post-migration carry `NULL` and the `IS NOT NULL` + * filter in `findNearestMatch` keeps them out of match candidacy. */ function addMissingColumns(db: Database.Database): void { const columns = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; diff --git a/tests/handler.test.ts b/tests/handler.test.ts index dc041c8..0dc7ca9 100644 --- a/tests/handler.test.ts +++ b/tests/handler.test.ts @@ -277,7 +277,7 @@ describe('runHandler', () => { // this so a regression that fans the embed out per source fails loudly. Without // this assertion, the InvokeModelCommand stub in beforeEach would silently absorb // any number of calls and the test would still pass. - expect(bedrock.commandCalls(InvokeModelCommand).length).toBeGreaterThanOrEqual(1); + expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(1); // And the Discord poster must actually post exactly once — without asserting this, // a swallowed fetch failure (the real fetch is spied in beforeEach) would let // runFetch catch the error and return success. From a77ec71a8962fd479c96c1e04012576bbb0bafa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:47:52 -0400 Subject: [PATCH 26/26] feat(loop): add loop-status script and docs Read-only companion to loop-start/loop-stop. Fetches LoopRuleName from stack outputs and prints the rule's State, ScheduleExpression, and Arn via aws events describe-rule. Same PROFILE/REGION/STACK_NAME conventions. README: add to the command block, document it as read-only, and point the redeploy-gotcha paragraph at it (run loop-status after a redeploy to confirm state before deciding whether to re-run loop-stop). package.json: expose as npm run loop-status. Co-Authored-By: Claude --- README.md | 16 ++++++++----- package.json | 3 ++- scripts/loop-status.sh | 52 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100755 scripts/loop-status.sh diff --git a/README.md b/README.md index 17dfffe..e46f01b 100644 --- a/README.md +++ b/README.md @@ -52,21 +52,25 @@ no token, no extra IAM grants: ```bash npm run loop-start # calls `aws events enable-rule` on the deployed rule npm run loop-stop # calls `aws events disable-rule` — no further ticks +npm run loop-status # reads `aws events describe-rule` — no side effects ``` While the loop is running, each tick posts one combined Discord message: a short friendly comment drawn from today's date, weather, and crypto price, ending with a haiku. If a past message in the corpus is close enough, the LLM's pre-suffix output -is mechanically appended with a `Reminds me of: ` line. Both scripts -read the rule name from the `LoopRuleName` stack output and call the EventBridge API -directly using the same AWS CLI credentials the smoke script already requires. +is mechanically appended with a `Reminds me of: ` line. All three +scripts read the rule name from the `LoopRuleName` stack output and call the +EventBridge API directly using the same AWS CLI credentials the smoke script +already requires. `loop-status` is read-only — print the rule's current +`ENABLED`/`DISABLED` state plus its schedule expression and ARN. **Stop the loop when you're done** — `loop-stop.sh` disables the EventBridge rule so no further invocations occur and the recurring AWS cost stops. Note: running `npm run deploy` after `loop-stop.sh` re-enables the rule, since the CDK stack -declares it `enabled: true` — re-run `loop-stop.sh` after any redeploy if you want -the loop to stay off. See [docs/07-budget-protection.md](docs/07-budget-protection.md) -for the per-day Bedrock call rate at 5-min cadence. +declares it `enabled: true` — run `npm run loop-status` after any redeploy to +confirm the state, then re-run `loop-stop.sh` if you want the loop to stay off. See +[docs/07-budget-protection.md](docs/07-budget-protection.md) for the per-day +Bedrock call rate at 5-min cadence. ## Triggering a fetch on demand diff --git a/package.json b/package.json index 5ad4942..78b670a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "deploy": "bash scripts/deploy.sh", "smoke": "bash scripts/smoke.sh", "loop-start": "bash scripts/loop-start.sh", - "loop-stop": "bash scripts/loop-stop.sh" + "loop-stop": "bash scripts/loop-stop.sh", + "loop-status": "bash scripts/loop-status.sh" }, "dependencies": { "better-sqlite3": "^13.0.1", diff --git a/scripts/loop-status.sh b/scripts/loop-status.sh new file mode 100755 index 0000000..92ad4d4 --- /dev/null +++ b/scripts/loop-status.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROFILE="${AWS_PROFILE:-default}" +REGION="${AWS_REGION:-us-east-1}" +STACK_NAME="SqliteS3AgentTutorial" + +echo "=== Fetching loop rule name ===" +RULE_NAME=$(aws cloudformation describe-stacks \ + --profile "$PROFILE" \ + --region "$REGION" \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='LoopRuleName'].OutputValue" \ + --output text) + +if [ -z "$RULE_NAME" ] || [ "$RULE_NAME" = "None" ]; then + echo "FAIL: stack $STACK_NAME has no LoopRuleName output — is the loop feature already deployed?" >&2 + exit 1 +fi + +echo "Rule: $RULE_NAME" + +echo "" +echo "=== Reading loop rule state ===" +# Read-only — does not enable or disable the rule. Use this to check state before +# running loop-start/loop-stop, or after a redeploy that may have re-enabled the rule. +RULE_JSON=$(aws events describe-rule \ + --name "$RULE_NAME" \ + --profile "$PROFILE" \ + --region "$REGION") + +STATE=$(echo "$RULE_JSON" | jq -r '.State // "UNKNOWN"') +SCHEDULE=$(echo "$RULE_JSON" | jq -r '.ScheduleExpression // "UNKNOWN"') +ARN=$(echo "$RULE_JSON" | jq -r '.Arn // "UNKNOWN"') + +echo "State: $STATE" +echo "Schedule: $SCHEDULE" +echo "ARN: $ARN" + +echo "" +case "$STATE" in + ENABLED) + echo "Loop is RUNNING — ticks fire every 5 minutes. Use 'npm run loop-stop' to pause." + ;; + DISABLED) + echo "Loop is PAUSED — no ticks will fire. Use 'npm run loop-start' to resume." + ;; + *) + echo "Unrecognized state — investigate before relying on this output." >&2 + exit 1 + ;; +esac