7 Commits

Author SHA1 Message Date
Paperclip Agent
89f56fc78a metrics: stand up the weekly funnel job (FRE-202)
`marketing/metrics/weekly.md` has been a placeholder since the marketing tree
landed — it promised a funnel and the one thing that moved, and nothing
produced it. This adds the producer: one command reads the live databases and
rewrites the file.

`scripts/weekly-metrics.ts` writes `marketing/metrics/weekly.md` from
`VisitorAnalytics` (visits, downloads, campaigns) and the-nook's `trials`,
`licenses`, `activations` (the paid funnel). It needs four env vars —
TURSO_DB_URL/TOKEN and NOOK_DB_URL/TOKEN — and imports no server env, so the
job runs from a checkout that has no Stripe or Apple secrets.
`src/server/weekly-metrics.ts` holds the queries and the renderer, taking both
connections as arguments; the tests run them against an in-memory SQLite shaped
like production, the same way the backfill suite does.

The campaign breakdown groups exactly as `getAnalyticsSummary.topCampaigns`
does (top 10, untagged excluded), so the weekly read and /analytics cannot
drift into different label sets.

The first real report says more about the instrumentation than about demand,
and the renderer computes that rather than asserting it:

- every addressed row the table has ever held (12,035 of 12,048) is loopback,
  so "unique visitors" is one host and per-channel reach cannot be read;
- the landing door and the file route both write `/api/downloads/thenook`, so
  "download click" and "DMG served" are one label — one served click writes two
  rows, a failed click one, a Sparkle update one;
- `POST /api/the-nook/checkout` records nothing, so checkout start is only a
  page-view proxy;
- `VisitorAnalytics` has no host dimension, so the Nook's `/` is freno.me's `/`.

Those three gaps are reported beside the numbers, not papered over. Week 0
(2026-10-22 → 2026-10-29): visit 61, download 156, trial 2, checkout 0,
purchase 0, activation 0 — and the one thing that moved is the download stage,
0 → 156, because the label was introduced that week.

Tests: `bun test src/server/weekly-metrics.test.ts` — 12 pass, 0 fail. The full
suite's 15 failures are pre-existing: this checkout's .env lacks
NOOK_LICENSE_PRIVATE_KEY, NOOK_STRIPE_SK/WEBHOOK/PRICE and LINEAGE_JWT_SECRET,
and every failure is that env validation.

Scripts/ is gitignored, so `scripts/weekly-metrics.ts` was added with `-f`, the
way the existing tracked scripts are.

FRE-202
2026-10-29 15:58:51 +00:00
Paperclip Agent
d44133fcb3 site: guard the removed remote claim and agent count against return (FRE-323, FRE-359) 2026-10-29 14:38:31 +00:00
Freno CMO
578ee58f9c marketing: the remote correction is committed, not deployed
copy.md said the sentences were "removed from the live site" and
rationale.md said "the landing page now says only what the product can back
today". Neither is true: nook.freno.me still serves all of them (HTTP 200,
re-fetched 2026-10-29T14:20Z) because the push is blocked on a write
credential (card 82d1e1b7). Both files now say committed-not-deployed, and
positioning.md's excluded-count row records FRE-359 verdict B and where the
count was removed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-29 14:22:12 +00:00
Freno CMO
d2c2e6a4ee agents: drop the count and the coverage claim (FRE-359 verdict B)
FRE-359's verdict is B: the page's thirteen is the registry's first thirteen
of eighteen rows, so the count, "every coding agent The Nook supports" and
"Supported agents" all overstate or understate the app. The permitted
sentences are the CTO's, verbatim.

- agents.tsx: head description, ogTitle and H1 paragraph replaced with the
  verdict's sentences; title/H1 "Supported agents" -> "Agent coverage".
- agents.test.ts: the stale NOOK-336 guard asserted "all thirteen" and
  the 13-name roster, so it passed in lockstep with the page. It now asserts
  no count of any form, no completeness phrase, and that the rows are a
  subset of the 18-name registry list (copied 2026-10-29, @ f4258034) in
  registry order. The table still lists 13 rows; regenerating it from the
  registry is its own ticket, and the guard no longer pretends otherwise.
- FeatureBreakdowns.tsx / IslandExtras.tsx: the landing "13 agents:" bullet
  and the "13 agents" marquee chip lose the count; the chip becomes
  "Agents wired in".

bun test src/routes/nook/agents.test.ts: 4 pass. Server security and appcast
suites fail in this checkout on missing env (Environment validation failed /
mainDBConnection), unrelated to these files.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-29 14:22:11 +00:00
Paperclip Agent
c718370674 /agents: take the remote-session sentence off the page
FRE-323 removed the remote claim from the landing page and missed the second
page that carries it. /agents said 'Agents can run on this Mac or on remote
machines over SSH — remote sessions report into the same grid', which is the
same unverified claim (FRE-315 §1, FRE-320), in front of the same launch
traffic. Removed; the paragraph now ends at 'the agent keeps its own prompting'.

Not deployed: same read-only credential as c1c498e.
2026-10-29 10:20:13 +00:00
Paperclip Agent
210a7e6e75 marketing: reconcile positioning.md with the CTO verdicts and the price decision
The source-of-truth file still sold claims the board has since ruled on:

- 'thirteen agents' as a public coverage count — FRE-322 §1 counts 18 specs,
  9 of which can raise an approval card; no count is publishable (FRE-203 plan-b).
- 'Price rises to 5 at 1.0' — withdrawn by the Chief of Staff on 2026-10-29
  04:01; price stays $10 and is not a launch asset.
- 'macOS 14+' — the app cannot start below 14.2 (881190d, site 134f41e).
- Approved-claims table carried no launch sentence at all, and no row for the
  remote-SSH hold or the banned absolute privacy sentences.
- Pointed at the-nook/docs/marketing-strategy.md, which no longer exists.

Now: the Plan B claim set, a per-claim status table with verdict pointers,
FRE-354's corrections to the hook/bridge sentence and the Sentry disclosure,
the iPhone TestFlight state, and an explicit excluded/banned list.
2026-10-29 10:19:14 +00:00
Paperclip Agent
c1c498e37f site: remove the unproven remote claim from the Nook landing page
FRE-323. The one-click remote install is app-UI-only and needs hand-edited ~/.ssh/config plus a one-time ssh-copy-id (FRE-320); FRE-315 and FRE-322 hold the claim unverified and demo-gated.

- drop the Remote tile and its mock from the feature collage
- drop the "Any SSH box, one click away" breakdown, its demo and bullets
- drop "Works across every hooked agent, local or remote"
- drop the "One-click SSH setup" chip
- move the copy to marketing/drafts/remote-ssh/ and mark remote held in marketing/positioning.md

No replacement claim was added. Deploy waits on a push credential for the GitHub repo Vercel builds.
2026-10-29 05:55:26 +00:00
15 changed files with 1570 additions and 234 deletions

View File

@@ -22,3 +22,12 @@ a human reviews and moves it to `approved/` → the human publishes from `approv
under their own account → the artifact (or a pointer) lands in `posted/`. under their own account → the artifact (or a pointer) lands in `posted/`.
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means. Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
## The weekly read
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.

View File

@@ -0,0 +1,79 @@
# Remote SSH copy — HELD, do not publish
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
correction, and **not yet off the live site** — the deploy is blocked on a write
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
serves every sentence below until that push or a Vercel deploy lands.
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
verifies that flow, and a recording shows a remote agent joining the island.
Until then this file is the only home for these sentences.
## What was removed
### 1. Landing collage tile
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Agents on other machines
body: Run agents on Linux boxes and SSH servers — one click installs the
nook-hook, and their sessions join the same island.
```
The mock attached to it showed two session rows whose agent chips named
`build-box` and `gpu-rig`.
### 2. Landing feature breakdown section
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Any SSH box, one click away
body: Pick a host from your ssh config or your tailnet and click once — the
nook-hook installs itself and its agents appear beside your local ones,
indistinguishable.
bullets:
- One click: connect, push the hook, configure agents, check it can reach this Mac
- Hosts mirror this Mac's installed integrations automatically
- Remote sessions are ordinary rows; the agent chip names the machine
```
The section's demo animated a `gpu-rig` host moving from "Not set up" through
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
host can reach this Mac` → `Live`, then showed a remote session row.
### 3. Approvals bullet
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
- Works across every hooked agent, local or remote
```
### 4. Feature chip
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
```
One-click SSH setup
```
## What replaced it
Nothing. The Remote tile and section were removed rather than replaced; the
existing Approvals cell already carries the verified local claim:
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
## Restore checklist
1. FRE-320/FRE-205 have shipped the one-click install and removed the
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
3. A recording exists showing a remote agent joining the island.
4. CMO restores the copy above, then owns the launch plan for it — with an
owner and date per item — before anything goes back on the site.

View File

@@ -0,0 +1,30 @@
# Why the remote claim is parked
The site was selling a setup path a buyer does not get.
FRE-320 measured today's actual path (2026-10-29): the one-click flow is
app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh
config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh
with `BatchMode=yes`, and has no manual fallback for minting the per-host
credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
unverified and demo-gated. The CTO named it the largest launch risk because it
was already in front of buyers.
So the `freno-dev` source now says only what the product can back today: local
agents, the card, and the notch. That correction is committed locally and is
**not deployed** — the live page still carries the remote sentences until the
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
install ships and is recorded.
## What has to be true before it returns
| Requirement | Owner | Date |
|---|---|---|
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
Nothing in this folder is publishable. `marketing/approved/` remains the only
place a human publishes from.

View File

@@ -1,4 +1,51 @@
# Weekly funnel snapshot # Weekly funnel snapshot
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`. Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days). Previous: 2026-10-15 16:00:00 → 2026-10-22 16:00:00 UTC.
Format: the funnel numbers, what moved, and the one thing worth acting on.
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
## Funnel
| Stage | This week | Last week | Change |
|---|---:|---:|---:|
| visit (page views) | 61 | 56 | +5 |
| download click → DMG served (one label) † | 156 | 0 | +156 |
| trial started | 2 | 0 | +2 |
| checkout page visit † | 0 | 0 | 0 |
| purchase (licence issued) | 0 | 0 | 0 |
| activation | 0 | 0 | 0 |
## Campaigns
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
| Source | Medium | Campaign | Rows |
|---|---|---|---:|
| hn | post | launch | 2 |
| direct | — | deploycheck-direct | 1 |
| hn | — | flushproof | 1 |
| hn | post | deploycheck-ref | 1 |
| r_macapps | — | week2 | 1 |
| x | — | — | 1 |
7 of 356 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
## The one thing that moved
**download click → DMG served (one label): 0 → 156 (+156).**
## Before acting on these numbers
- **356 of 356 rows in the window came from a loopback address** (127.0.0.1 / ::1); every one of the 12035 addressed rows the table has ever held is loopback (13 carry no address). Either this host is the only audience, or the app sits behind a local proxy and `getRequestIP` records the proxy, not the visitor. Either way the address identifies nobody — "unique visitors" collapses to one host and per-channel reach cannot be read from it.
- **The download stage is one label, not two.** The landing-door route and the file route that serves the DMG both write `/api/downloads/thenook`, so "download click → DMG served" cannot be split: one served click writes two rows, a click that fails to resolve writes one, and a Sparkle update writes one. Of this window's 156 download rows, 156 are loopback and 117 are not browser user agents (curl, Sparkle, link-preview crawlers).
- **Checkout start is not recorded.** `POST /api/the-nook/checkout` — the call that actually creates the Stripe session — writes nothing to analytics; the only trace is the /checkout page view reported above, which is a page, not a started checkout.
- **There is no host dimension.** `VisitorAnalytics` records a path, not the site, so the Nook's landing page (`/` on nook.freno.me) is the same row as freno.me's root. "visit" above is site-wide page views, not Nook visits.
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
† Proxy or merged label — see *Before acting on these numbers* above.
## Sources
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
- Generated 2026-10-29T16:00:00Z.

View File

@@ -4,13 +4,37 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
artifact invents its own framing, fix the artifact or fix this file — never both artifact invents its own framing, fix the artifact or fix this file — never both
silently diverging. silently diverging.
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`. **Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322],
[FRE-354]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
no longer exists; the verdicts below are the source of truth it was pointing at.
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
## The sentence ## The sentence
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent There is no single approved public sentence yet, and saying otherwise is the one
> fleet in the notch — thirteen agents, local or over SSH — so you see what is running, mistake this file exists to prevent. What exists is a **launch claim set** — two
> unblock it in one tap, and keep the machine from overheating while it works. sentences, both from one 15-second take, recorded as the chosen route by the Chief
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
| Slot | Exact sentence | Status today |
|---|---|---|
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
Working line for internal writing, not for publication:
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
> see what is running, you unblock what is waiting, and the machine stays cool
> while it works.
## The wedge ## The wedge
@@ -22,15 +46,18 @@ The thing to say over and over, in every channel, in slightly different words:
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
Allow, work resumes. Ten seconds, no explanation needed. Allow, work resumes. Ten seconds, no explanation needed.
## The bundling reframe ## The consolidation reframe
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story: app reads like a grab bag. Reframe as consolidation, which is the honest story:
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar > One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
> stats strip, and a notification hack. Same one-time purchase, no subscription. > strip, and a notification hack. One purchase, no subscription.
Price rises to $15 at 1.0 — dated and honest, not a permanent beta. **No price, no discount and no future price in copy.** The price is $10, it stays
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
price change is a Chief of Staff + sponsor call; it is never a marketing decision
and never a launch asset.
## Hard rules ## Hard rules
@@ -48,24 +75,49 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
community replies written by a human, where quoting what a user said about another community replies written by a human, where quoting what a user said about another
tool is normal conversation. tool is normal conversation.
### 2. A claim requires a test to exist ### 2. A claim needs a verdict, not just a test
Copy that says "13 agents" or "no data leaves the device" must have a test backing it. Copy that says "every agent", "13 agents", "no data leaves the device" must point at
If a claim cannot be pointed at code or a test, it does not go in copy. both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
tables below, it does not go in copy.
### 3. Honest privacy scoping ### 3. Honest privacy scoping
Telemetry and the trial email **do** leave the device. The defensible claim is: Telemetry and the licence check **do** leave the device: Sentry runs on **every
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
> **No prompt or code content leaves the device.** - "no data leaves the device" / "local-only" / "no telemetry" / "no account"
- "nothing leaves your Mac"
Never "no data leaves the device". The one favourable engine sentence — about the **hook and bridge only**, never
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
service**. Do not write "talk to nothing but your Mac" (too absolute: the
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
it to "over SSH" (a tailnet peer dials directly).
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
**held** until the disclosures land: Sentry gated behind a preference or removed,
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
false ones is worse than no sentence.
### 4. Sell what works today ### 4. Sell what works today
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)" An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
parentheticals. The launch story is remote SSH: agents running on other machines, (`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
visible and unblockable from the Mac's notch. one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
all, and never claim approvals-from-phone as a shipped capability without its own
CTO verdict — no "(coming soon)" parentheticals either way.
Remote SSH is **held**, not approved: the one-click install has not
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
recording shows a remote agent joining the notch.
### 5. Humans hold every account ### 5. Humans hold every account
@@ -73,23 +125,29 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
without disclosure. `approved/` **is the only thing a human ever publishes from** — without disclosure. `approved/` **is the only thing a human ever publishes from** —
nothing posts itself. nothing posts itself.
## Approved claims (each backed by code) ## Claims: status at 2026-10-29
| Claim | Backing | Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|---|---|
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek | | Claim | Status | Evidence / next step |
| Remote agents over SSH | `hook/setup.go` remote install path; `NOOK_HOST` TCP transport | |---|---|---|
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) | | Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) | | Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout | | The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
| macOS 14+ | 27 source files use the `@Observable` framework | | Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) | | One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
| Agent coverage numbers ("thirteen agents", "8 agents", "all your agents") | **Excluded — verdict B, count removed** | [FRE-359] verdict B (2026-10-29): the page's 13 rows are the registry's first 13 of 18, so no number is publishable and `every` / `all` / `the agents The Nook supports` are banned. Removed from `agents.tsx`, the landing island section and the marquee. [FRE-322] §1 matrix for the sets. |
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
## Voice ## Voice
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents", - Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage
"one tap", "$10 once"). counts (see the excluded row above).
- The notch/island is the product's home; agents are the product's point. - The notch is the product's home; agents are the product's point.
- Never apologize for being a utility. The utility is the point. - Never apologize for being a utility. The utility is the point.
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary", - Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
"game-changer", "seamlessly". Unverifiable superlatives generally. "game-changer", "seamlessly", any unverifiable superlative — and the specific
banned sentences in rule 3 and the table above.

View File

@@ -12,6 +12,7 @@
"test:coverage": "bun test --coverage", "test:coverage": "bun test --coverage",
"perf": "bun run scripts/perf-test.ts", "perf": "bun run scripts/perf-test.ts",
"perf:compare": "bun run scripts/perf-compare.ts", "perf:compare": "bun run scripts/perf-compare.ts",
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts" "nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
}, },
"dependencies": { "dependencies": {

153
scripts/weekly-metrics.ts Normal file
View File

@@ -0,0 +1,153 @@
import { createClient } from "@libsql/client/web";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
getWeeklyReport,
renderWeeklyMarkdown,
type Connections
} from "~/server/weekly-metrics";
/**
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
*
* bun --env-file=.env scripts/weekly-metrics.ts
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
*
* Required environment (all four; the analytics token also opens the licence
* DB, which is why the weekly job can run from a checkout that has no Stripe
* or Apple secrets):
*
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
*
* This script deliberately does not import `~/env/server`: that schema demands
* every production secret, and the metrics job needs two databases and nothing
* else.
*
* The output path is anchored at the repo root, so running it from any cwd
* writes the same file.
*/
interface Options {
days: number;
now: Date;
out: string;
stdout: boolean;
}
function usage(): string {
return [
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
"",
" --days N window length in days (default 7)",
" --now ISO end of the window (default: now)",
" --out PATH where to write (default: marketing/metrics/weekly.md)",
" --stdout print the report instead of writing a file"
].join("\n");
}
function parseArgs(argv: string[]): Options {
const repoRoot = resolve(import.meta.dir, "..");
const options: Options = {
days: 7,
now: new Date(),
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
stdout: false
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
const value = () => {
const next = argv[i + 1];
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
i += 1;
return next;
};
switch (arg) {
case "--days": {
const days = Number(value());
if (!Number.isFinite(days) || days <= 0) {
throw new Error(`--days must be a positive number\n\n${usage()}`);
}
options.days = days;
break;
}
case "--now": {
const now = new Date(value());
if (Number.isNaN(now.getTime())) {
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
}
options.now = now;
break;
}
case "--out":
options.out = resolve(value());
break;
case "--stdout":
options.stdout = true;
break;
case "-h":
case "--help":
console.log(usage());
process.exit(0);
default:
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
}
}
return options;
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value || value.trim() === "") {
throw new Error(
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
);
}
return value;
}
function connect(url: string, token: string) {
return createClient({ url, authToken: token });
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const connections: Connections = {
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
};
const report = await getWeeklyReport(connections, {
days: options.days,
now: options.now
});
const markdown = renderWeeklyMarkdown(report);
if (options.stdout) {
process.stdout.write(markdown);
return;
}
await mkdir(dirname(options.out), { recursive: true });
await writeFile(options.out, markdown, "utf8");
const f = report.counts;
console.log(
`wrote ${options.out}\n` +
`window ${report.window.start} → ${report.window.end}\n` +
`visit ${f.visit}, download ${f.download}, trial ${f.trial}, ` +
`checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -1,6 +1,5 @@
import { import {
For, For,
Show,
createEffect, createEffect,
createSignal, createSignal,
onCleanup, onCleanup,
@@ -21,7 +20,6 @@ import { Campfire, type CampfireState } from "./Campfire";
import { import {
ApprovalCardMock, ApprovalCardMock,
SessionRowMock, SessionRowMock,
Glyph,
CLAUDE, CLAUDE,
CODEX CODEX
} from "./ModuleMocks"; } from "./ModuleMocks";
@@ -37,7 +35,7 @@ import {
* Detailed feature breakdowns: alternating copy + live-demo sections. * Detailed feature breakdowns: alternating copy + live-demo sections.
* Each demo stages the real interaction loop: attention flares, approval * Each demo stages the real interaction loop: attention flares, approval
* resolves, fans throttle, the charge ceiling holds, meetings surface, * resolves, fans throttle, the charge ceiling holds, meetings surface,
* the camera wakes, a remote host goes live. Newer demos live in * the camera wakes. Newer demos live in
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx. * MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
*/ */
@@ -807,112 +805,6 @@ function CameraDemo() {
); );
} }
/* ── Demo 7: Settings ▸ Remote hosts — one click to Live ──────────── */
/** RemoteMirror's progress steps, in order. */
const SETUP_STEPS = [
"connecting",
"pushing the hook binary",
"configuring agents",
"checking the host can reach this Mac"
];
function RemoteHostDemo() {
/* -1 not set up · 0…3 setting up · 4 live */
const [step, setStep] = createSignal(-1);
const [pressed, setPressed] = createSignal(false);
useDemoLoop(11000, [
[0, () => (setStep(-1), setPressed(false))],
[1400, () => setPressed(true)],
[1700, () => (setPressed(false), setStep(0))],
[2700, () => setStep(1)],
[3700, () => setStep(2)],
[4700, () => setStep(3)],
[5800, () => setStep(4)]
]);
const live = () => step() === 4;
const working = () => step() >= 0 && step() < 4;
const chip = () =>
live()
? { color: "rgb(48,209,88)", label: "Live" }
: working()
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
return (
<div class="relative mx-auto w-full max-w-[360px] space-y-3">
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
<ModuleCard>
<div class="space-y-2 text-left">
<div class="flex items-center gap-2.5">
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
</div>
<Show when={working()}>
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
</Show>
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
<span
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
style={{ background: chip().color }}
/>
{chip().label}
</span>
<Show
when={live()}
fallback={
<span
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
style={{
background: ACCENT,
opacity: working() ? 0 : 1,
transform: pressed() ? "scale(0.92)" : "scale(1)"
}}
>
Set up
</span>
}
>
<Glyph name="refresh" size={11} class="text-white/50" />
<span class="rounded-md bg-white/10 px-2 py-1 text-[11px] font-semibold text-[#ff6961]">
Remove
</span>
</Show>
</div>
<p class="h-4 text-[11px] text-white/50">
{working() ? SETUP_STEPS[step()] : " "}
</p>
</div>
</ModuleCard>
{/* its agents arrive as ordinary rows, the machine named in the chip */}
<div
class="transition-all duration-500"
style={{
opacity: live() ? 1 : 0,
transform: live() ? "translateY(0)" : "translateY(6px)"
}}
>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Running python bench.py…"
status="running"
agent={CODEX}
host="gpu-rig"
origin="iTerm2"
age="now"
/>
</div>
<FakeCursor
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
at={{ x: 88, y: 16 }}
/>
</div>
);
}
/* ── Demo 8: campfire states ───────────────────────────────────────── */ /* ── Demo 8: campfire states ───────────────────────────────────────── */
function CampfireDemo() { function CampfireDemo() {
@@ -959,7 +851,7 @@ export default function FeatureBreakdowns() {
title="Your whole fleet, in one pixel strip" title="Your whole fleet, in one pixel strip"
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back." body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
bullets={[ bullets={[
"13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more", "Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor and more",
"Custom agents: your own coding agent can wire itself in from the published contract", "Custom agents: your own coding agent can wire itself in from the published contract",
"Slot rankings — you choose which data earns the notch", "Slot rankings — you choose which data earns the notch",
"You don't need a notch — external displays get the same island, floating top-center" "You don't need a notch — external displays get the same island, floating top-center"
@@ -976,7 +868,6 @@ export default function FeatureBreakdowns() {
"Approval cards pin above the session list; questions answer inside their row", "Approval cards pin above the session list; questions answer inside their row",
"Allow for session waves through the rest of that session's requests", "Allow for session waves through the rest of that session's requests",
"Answer from iPhone or Apple Watch too, now in TestFlight beta", "Answer from iPhone or Apple Watch too, now in TestFlight beta",
"Works across every hooked agent, local or remote"
]} ]}
reversed reversed
> >
@@ -996,20 +887,6 @@ export default function FeatureBreakdowns() {
<TerminalReplyDemo /> <TerminalReplyDemo />
</BreakdownSection> </BreakdownSection>
<BreakdownSection
kicker="Remote"
title="Any SSH box, one click away"
body="Pick a host from your ssh config or your tailnet and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
bullets={[
"One click: connect, push the hook, configure agents, check it can reach this Mac",
"Hosts mirror this Mac's installed integrations automatically",
"Remote sessions are ordinary rows; the agent chip names the machine"
]}
reversed
>
<RemoteHostDemo />
</BreakdownSection>
<BreakdownSection <BreakdownSection
kicker="The campfire" kicker="The campfire"
title="A hearth that tells the truth" title="A hearth that tells the truth"

View File

@@ -1,12 +1,6 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock"; import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
import { import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
ApprovalCardMock,
SessionRowMock,
DieLine,
CLAUDE,
CODEX
} from "./ModuleMocks";
/** /**
* Feature collage: five cells sold as the app's actual UI. The hero cell * Feature collage: five cells sold as the app's actual UI. The hero cell
@@ -105,35 +99,6 @@ function FansPanelMock() {
); );
} }
/**
* Remote agents as the panel draws them: ordinary session rows whose agent
* chip names the machine ("Claude Code · build-box").
*/
function RemoteMock() {
return (
<div class="space-y-1.5">
<SessionRowMock
title="api · main"
prompt="fix the flaky test"
state="Running pytest -x…"
status="running"
agent={CLAUDE}
host="build-box"
age="4m"
/>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Done · replied 1m ago"
status="ready"
agent={CODEX}
host="gpu-rig"
age="12m"
/>
</div>
);
}
/* Deterministic usage wander for the traces. */ /* Deterministic usage wander for the traces. */
const wander = (n: number, k: number) => const wander = (n: number, k: number) =>
0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k); 0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k);
@@ -256,19 +221,6 @@ export default function FeatureCollage() {
</InkStage> </InkStage>
</CollageCell> </CollageCell>
{/* Remote agents */}
<CollageCell
kicker="Remote"
title="Agents on other machines"
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
>
<InkStage class="w-full">
<div class="w-full px-3 text-left">
<RemoteMock />
</div>
</InkStage>
</CollageCell>
{/* Gauges */} {/* Gauges */}
<CollageCell <CollageCell
kicker="Your Mac" kicker="Your Mac"

View File

@@ -722,7 +722,7 @@ function LayoutMock() {
/* ── Marquee ─────────────────────────────────────────────────────── */ /* ── Marquee ─────────────────────────────────────────────────────── */
const CHIPS_A = [ const CHIPS_A = [
"13 agents", "Agents wired in",
"Custom agents", "Custom agents",
"Six chiptunes", "Six chiptunes",
"Focus-aware slots", "Focus-aware slots",
@@ -740,7 +740,6 @@ const CHIPS_B = [
"tmux · iTerm2 · kitty", "tmux · iTerm2 · kitty",
"Markdown replies", "Markdown replies",
"Tailscale hosts", "Tailscale hosts",
"One-click SSH setup",
"iPhone widgets", "iPhone widgets",
"Watch-face widgets", "Watch-face widgets",
"Screenshots to clipboard", "Screenshots to clipboard",

View File

@@ -1,5 +1,6 @@
/** /**
* Regression test for the /agents roster (NOOK-336). * Regression test for the /agents roster (NOOK-336) and its coverage wording
* (FRE-359).
* *
* The page mirrors the app's registry, `AgentSpec.all` in * The page mirrors the app's registry, `AgentSpec.all` in
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined * the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
@@ -8,6 +9,12 @@
* them. The registry lives in another repo, so the expected roster is * them. The registry lives in another repo, so the expected roster is
* restated here; when an agent is added there, add it here and on the page. * restated here; when an agent is added there, add it here and on the page.
* *
* FRE-359 verdict B: the page no longer states a count or claims coverage
* ("all thirteen", "every coding agent The Nook supports"). The page still
* lists 13 of the registry's 18 rows — the table is hand-mirrored and its
* regeneration is its own ticket — so the roster check here is a subset
* check in registry order, not an equality check.
*
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions. * STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
*/ */
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
@@ -16,7 +23,10 @@ import { join } from "node:path";
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8"); const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
/** AgentSpec.all display names, in registry order. */ /** User-facing copy only: comments are not copy and may name the old wording. */
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
const REGISTRY = [ const REGISTRY = [
"Claude Code", "Claude Code",
"Codex", "Codex",
@@ -30,25 +40,59 @@ const REGISTRY = [
"Gemini CLI", "Gemini CLI",
"Cline", "Cline",
"Qwen Code", "Qwen Code",
"DeepSeek" "DeepSeek",
"Kimi",
"GitHub Copilot CLI",
"Crush",
"Cursor",
"Amp"
]; ];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"]; const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]); const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
/** The page's rows are a subset of the registry, in registry order. */
function subsetInOrder(page: string[], registry: string[]): boolean {
let cursor = 0;
for (const name of page) {
const at = registry.indexOf(name, cursor);
if (at === -1) return false;
cursor = at + 1;
}
return true;
}
describe("Nook /agents roster (NOOK-336)", () => { describe("Nook /agents roster (NOOK-336)", () => {
it("lists every registry agent, in registry order", () => { it("lists only registry agents, in registry order", () => {
expect(entries).toEqual(REGISTRY); expect(entries.length).toBeGreaterThan(0);
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
}); });
it("states the same count everywhere it names one", () => { it("lists each agent at most once", () => {
const word = NUMBER_WORDS[entries.length - 12]; expect(new Set(entries).size).toBe(entries.length);
for (const other of NUMBER_WORDS.filter((w) => w !== word)) { });
expect(SOURCE.toLowerCase()).not.toContain(other); });
describe("Nook /agents coverage wording (FRE-359)", () => {
it("states no agent count anywhere on the page", () => {
for (const word of NUMBER_WORDS) {
expect(COPY.toLowerCase()).not.toContain(word);
}
// A bare numeral count ("13 agents", "The 13") is just as precise and just
// as unverified; the page must not state one in any form.
expect(COPY).not.toMatch(/\b\d{1,2} agents\b/);
expect(COPY).not.toMatch(/\ball \d{1,2}\b/);
});
it("makes no completeness claim over the agent set", () => {
for (const banned of [
"every coding agent",
"the agents the nook supports",
"agents the nook supports",
"supported agents"
]) {
expect(COPY.toLowerCase()).not.toContain(banned);
} }
expect(SOURCE).toContain(`all ${word}`);
expect(SOURCE).toContain(`The ${entries.length} agents`);
expect(SOURCE).toContain(`watches ${word} coding agents`);
}); });
}); });

View File

@@ -1,5 +1,7 @@
/** /**
* `/agents` on the Nook subdomain — supported agents and their event coverage. * `/agents` on the Nook subdomain — the agent roster and each row's event
* coverage. It states no agent count and claims no completeness over the
* registry (FRE-359 verdict B).
* *
* The registry below mirrors `AgentSpec.all` in the app * The registry below mirrors `AgentSpec.all` in the app
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's * (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
@@ -282,26 +284,25 @@ export default function NookAgentsPage() {
<> <>
<EdgeCacheHeaders maxAge={300} /> <EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Supported agents" title="Agent coverage"
description="Every coding agent The Nook supports — all thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work." description="The coding agents The Nook connects to, with the events each hook reports and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
ogTitle="The 13 agents The Nook supports, and what each hook sees" ogTitle="The coding agents The Nook connects to, and what each hook sees"
/> />
<SubdomainHeader /> <SubdomainHeader />
<main class="min-h-screen px-[8vw] py-[8vh]"> <main class="min-h-screen px-[8vw] py-[8vh]">
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl"> <h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
Supported agents Agent coverage
</h1> </h1>
<p class="text-subtext0 mb-2 max-w-2xl text-lg"> <p class="text-subtext0 mb-2 max-w-2xl text-lg">
The Nook watches thirteen coding agents in one place — your menu bar and The Nook watches coding agents in one place — your menu bar and notch
notch show every session that is running, thinking, or blocked, and a show sessions that are running, thinking, or blocked, and a card can
card answers permission prompts without leaving your keyboard. answer a permission prompt without leaving your keyboard.
</p> </p>
<p class="text-subtext1 mb-10 max-w-2xl text-sm"> <p class="text-subtext1 mb-10 max-w-2xl text-sm">
Each agent reports its own set of events. "Allow / Deny from a card" Each agent reports its own set of events. "Allow / Deny from a card"
means the agent hands the decision to The Nook while it waits; means the agent hands the decision to The Nook while it waits;
"Observe" means every event still appears live, but the agent keeps "Observe" means every event still appears live, but the agent keeps
its own prompting. Agents can run on this Mac or on remote machines its own prompting.
over SSH — remote sessions report into the same grid.
</p> </p>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">

View File

@@ -0,0 +1,114 @@
/**
* Regression test for the removed remote / SSH claims and the removed agent
* count (FRE-323, FRE-359 verdict B).
*
* FRE-323 took the remote story off the site because it was unverified and
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
* needs a one-time `ssh-copy-id` because the app runs ssh with
* `BatchMode=yes`, and has no manual fallback for minting the per-host
* credential. FRE-359 verdict B removed the coverage count for the same
* reason: the /agents table lists 13 of the registry's 18 rows.
*
* The copy was not thrown away — it is parked in
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
* it is still there so nobody deletes the parked story as stale. It goes back
* on the site only when the one-click install ships and is recorded.
*
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
*
* Deliberate exceptions, not audited here — both were weighed with the Chief
* of Staff at the go/no-go and left in place on purpose:
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
* it" is a limitation disclosure, not a capability claim.
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
* the removed remote story, but tailnet host lookup does exist. Raised on
* FRE-323 rather than widened into this correction.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "../../..");
/** Shipped page and component source. Tests and drafts may quote the old copy. */
const AUDITED = [
"src/routes/nook/index.tsx",
"src/routes/nook/agents.tsx",
"src/routes/nook/faq.tsx",
"src/components/nook/FeatureCollage.tsx",
"src/components/nook/FeatureBreakdowns.tsx",
"src/components/nook/IslandExtras.tsx"
];
/** Every sentence FRE-323 ordered off the site. */
const BANNED_REMOTE_CLAIMS = [
"Agents on other machines",
"one click installs the nook-hook",
"Any SSH box",
"Works across every hooked agent, local or remote",
"Remote sessions are ordinary rows",
"One-click SSH setup",
"remote machines over SSH",
"this Mac or remote machines"
];
/** FRE-359 verdict B: no count, no coverage claim, in any form. */
const BANNED_COVERAGE = [
"all thirteen",
"thirteen coding agents",
"13 agents",
"every coding agent",
"Supported agents"
];
/** User-facing copy only: comments are not copy and may name the old wording. */
function copyOf(file: string): string {
return readFileSync(join(ROOT, file), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
}
describe("remote claims stay off the site (FRE-323)", () => {
for (const file of AUDITED) {
it(`${file} does not restore a removed remote sentence`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_REMOTE_CLAIMS) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
});
}
it("/agents no longer offers remote sessions", () => {
const copy = copyOf("src/routes/nook/agents.tsx");
expect(copy).not.toContain("remote sessions report into the same grid");
expect(copy).not.toContain("on this Mac or on remote");
});
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
expect(parked).toContain(phrase);
}
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
"FRE-320"
);
});
});
describe("agent coverage count stays off the page (FRE-359 verdict B)", () => {
for (const file of AUDITED) {
it(`${file} states no coverage count`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_COVERAGE) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
});
}
it("/agents titles the table Agent coverage", () => {
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Agent coverage");
});
});

View File

@@ -0,0 +1,381 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Database } from "bun:sqlite";
import {
VISITOR_ANALYTICS_COLUMNS,
VISITOR_ANALYTICS_DDL,
VISITOR_ANALYTICS_INSERT
} from "./analytics-schema";
import {
NOOK_DOWNLOAD_PATH,
getWeeklyReport,
pickMover,
readCampaigns,
readFunnel,
readTraffic,
renderWeeklyMarkdown,
windowsFor,
type Connections,
type FunnelCounts,
type MetricsConn
} from "./weekly-metrics";
// Same reasoning as the backfill suite: the queries run real SQL, so an
// in-memory SQLite shaped like production is the only honest stand-in. A stub
// that returned canned rows would pass with a column or a label the live table
// does not have.
const NOOK_DDL = [
`CREATE TABLE trials (
fingerprint TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
email TEXT
)`,
`CREATE TABLE licenses (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
stripe_session_id TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE activations (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL,
device_fingerprint TEXT NOT NULL,
device_name TEXT NOT NULL,
activated_at TEXT NOT NULL,
deactivated_at TEXT
)`
];
function adapter(db: Database): MetricsConn {
return {
async execute(query) {
const args = (query.args ?? []) as never[];
const statement = db.query(query.sql);
if (/^\s*(select|pragma|with)\b/i.test(query.sql)) {
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
}
return {
rows: [] as unknown[],
rowsAffected: statement.run(...args).changes
};
}
};
}
let analyticsDb: Database;
let nookDb: Database;
let conns: Connections;
const NOW = new Date("2026-10-29T16:00:00Z");
const row = (
overrides: Record<string, unknown>
): Record<string, unknown> => ({
id: crypto.randomUUID(),
user_id: null,
path: "/",
method: "GET",
referrer: null,
user_agent: "Mozilla/5.0 (Macintosh)",
ip_address: "203.0.113.9",
country: "GB",
device_type: "desktop",
browser: "safari",
os: "macos",
utm_source: null,
utm_medium: null,
utm_campaign: null,
duration_ms: null,
fcp: null,
lcp: null,
cls: null,
fid: null,
inp: null,
ttfb: null,
dom_load: null,
load_complete: null,
...overrides
});
function insertAnalytics(overrides: Record<string, unknown>): void {
const values = row(overrides);
analyticsDb
.prepare(VISITOR_ANALYTICS_INSERT)
.run(...(VISITOR_ANALYTICS_COLUMNS.map((column) => values[column]) as never[]));
// `created_at` is not in VISITOR_ANALYTICS_COLUMNS (it defaults to now), so
// the window each row belongs to has to be set after the insert.
if (values.created_at) {
analyticsDb
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
.run(values.created_at as string, values.id as string);
}
}
// Inside the current window for NOW - 7 days: 2026-10-22 16:00 → 2026-10-29 16:00.
const THIS_WEEK = "2026-10-27 10:00:00";
// Inside the previous window: 2026-10-15 16:00 → 2026-10-22 16:00.
const LAST_WEEK = "2026-10-18 10:00:00";
beforeEach(() => {
analyticsDb = new Database(":memory:");
analyticsDb.run(VISITOR_ANALYTICS_DDL);
nookDb = new Database(":memory:");
for (const ddl of NOOK_DDL) nookDb.run(ddl);
conns = { analytics: adapter(analyticsDb), nook: adapter(nookDb) };
});
describe("windowsFor", () => {
it("puts the previous window directly before the current one", () => {
const windows = windowsFor(NOW, 7);
expect(windows.current).toEqual({
start: "2026-10-22 16:00:00",
end: "2026-10-29 16:00:00"
});
expect(windows.previous).toEqual({
start: "2026-10-15 16:00:00",
end: "2026-10-22 16:00:00"
});
});
});
describe("readFunnel", () => {
it("counts each stage inside its window and nothing outside it", async () => {
// Page views count; API calls do not.
insertAnalytics({ path: "/", created_at: THIS_WEEK });
insertAnalytics({ path: "/downloads", created_at: THIS_WEEK });
insertAnalytics({ path: "/api/trpc/anything", created_at: THIS_WEEK });
insertAnalytics({ path: "/", created_at: LAST_WEEK });
// The merged download label, click and served alike.
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK });
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK });
// Checkout page doors, including the legacy /nook/checkout path.
insertAnalytics({ path: "/checkout", created_at: THIS_WEEK });
insertAnalytics({ path: "/nook/checkout", created_at: THIS_WEEK });
// The licence DB stores ISO-8601 with milliseconds.
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-1", "2026-10-28T20:28:46.806Z");
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-old", "2026-08-26T19:26:12.791Z");
nookDb
.prepare(
`INSERT INTO licenses (id, key, email, stripe_session_id, created_at)
VALUES (?, ?, ?, ?, ?)`
)
.run("lic-1", "NOOK-1", "buyer@example.com", "cs_1", "2026-10-25T12:00:00.000Z");
nookDb
.prepare(
`INSERT INTO activations (id, license_id, device_fingerprint, device_name, activated_at)
VALUES (?, ?, ?, ?, ?)`
)
.run("act-1", "lic-1", "device-1", "Hermes", "2026-10-25T12:05:00.000Z");
const window = windowsFor(NOW, 7).current;
const funnel = await readFunnel(conns, window);
// "/checkout" and "/nook/checkout" are page views too, so they land in
// both the visit count and the checkout count.
expect(funnel).toEqual({
visit: 4,
download: 2,
trial: 1,
checkout: 2,
purchase: 1,
activation: 1
});
});
it("reports zeros against an empty database", async () => {
const window = windowsFor(NOW, 7).current;
expect(await readFunnel(conns, window)).toEqual({
visit: 0,
download: 0,
trial: 0,
checkout: 0,
purchase: 0,
activation: 0
});
});
});
describe("readTraffic", () => {
it("separates loopback and automation from browser traffic", async () => {
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "::1",
user_agent: "The Nook/0.13.4 Sparkle/2.9.6",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "203.0.113.9",
user_agent: "Mozilla/5.0 (Macintosh) Safari/605.1.15",
created_at: THIS_WEEK
});
insertAnalytics({ path: "/", ip_address: "203.0.113.9", created_at: THIS_WEEK });
const traffic = await readTraffic(conns, windowsFor(NOW, 7).current);
expect(traffic).toEqual({
analyticsRows: 4,
loopbackRows: 2,
allTimeRows: 4,
allTimeAddressedRows: 4,
allTimeLoopbackRows: 2,
downloadRows: 3,
downloadLoopbackRows: 2,
downloadNonBrowserRows: 2
});
});
});
describe("readCampaigns", () => {
it("groups tagged rows, excludes untagged, and orders by volume", async () => {
insertAnalytics({
path: "/",
utm_source: "hn",
utm_medium: "post",
utm_campaign: "launch",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
utm_source: "hn",
utm_medium: "post",
utm_campaign: "launch",
created_at: THIS_WEEK
});
insertAnalytics({
path: "/",
utm_source: "r_macapps",
utm_campaign: "week2",
created_at: THIS_WEEK
});
insertAnalytics({ path: "/", created_at: THIS_WEEK });
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
expect(campaigns).toEqual([
{ source: "hn", medium: "post", campaign: "launch", count: 2 },
{ source: "r_macapps", medium: null, campaign: "week2", count: 1 }
]);
});
it("caps the list at ten rows", async () => {
for (let i = 0; i < 12; i += 1) {
insertAnalytics({
path: "/",
utm_source: `source-${i}`,
created_at: THIS_WEEK
});
}
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
expect(campaigns.length).toBe(10);
});
});
describe("pickMover", () => {
const base: FunnelCounts = {
visit: 0,
download: 0,
trial: 0,
checkout: 0,
purchase: 0,
activation: 0
};
it("picks the stage with the largest absolute change", () => {
const mover = pickMover(
{ ...base, visit: 61, download: 156, trial: 2 },
{ ...base, visit: 56 }
);
expect(mover).toEqual({
key: "download",
label: "download click → DMG served (one label)",
now: 156,
before: 0,
delta: 156
});
});
it("breaks ties toward the earlier stage", () => {
const mover = pickMover(
{ ...base, visit: 5, download: 5 },
{ ...base, visit: 0, download: 0 }
);
expect(mover?.key).toBe("visit");
});
it("returns null when nothing moved", () => {
expect(pickMover({ ...base, visit: 3 }, { ...base, visit: 3 })).toBeNull();
});
});
describe("getWeeklyReport / renderWeeklyMarkdown", () => {
it("reports a week in the agreed stage order with the computed caveats", async () => {
insertAnalytics({ path: "/", created_at: THIS_WEEK });
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
insertAnalytics({
path: NOOK_DOWNLOAD_PATH,
ip_address: "127.0.0.1",
user_agent: "curl/8.7.1",
created_at: THIS_WEEK
});
nookDb
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
.run("fp-1", "2026-10-28T20:28:46.806Z");
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(report.mover?.key).toBe("download");
expect(markdown).toContain(
"Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days)"
);
expect(markdown).toContain("| visit (page views) | 1 | 0 | +1 |");
expect(markdown.indexOf("visit (page views)")).toBeLessThan(
markdown.indexOf("download click → DMG served")
);
expect(markdown.indexOf("download click → DMG served")).toBeLessThan(
markdown.indexOf("trial started")
);
expect(markdown).toContain("**download click → DMG served (one label): 0 → 2 (+2).**");
expect(markdown).toContain("2 of 3 rows in the window came from a loopback address");
expect(markdown).toContain("Checkout start is not recorded");
expect(markdown).toContain("There is no host dimension");
});
it("says nothing moved rather than inventing a winner", async () => {
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
const markdown = renderWeeklyMarkdown(report);
expect(report.mover).toBeNull();
expect(markdown).toContain("_Nothing moved: every stage held its previous-week value._");
expect(markdown).toContain("_No tagged traffic in the window._");
});
it("rejects a window that is not a positive number of days", async () => {
await expect(getWeeklyReport(conns, { now: NOW, days: 0 })).rejects.toThrow(
"days must be a positive number"
);
});
});

View File

@@ -0,0 +1,591 @@
/**
* The weekly funnel reading: `marketing/metrics/weekly.md`'s data half.
*
* The funnel the marketing cluster agreed on is
*
* visit → download click → DMG served → trial started → checkout started
* → purchase → activation
*
* and this module reads it from the two live databases, plus the campaign
* dimension the /analytics page already exposes through
* `getAnalyticsSummary.topCampaigns`.
*
* Two properties matter more than the numbers:
*
* - It takes both connections as arguments. `~/server/analytics` imports the
* full server env (Stripe keys, Apple secret, licence key …); the weekly
* job only needs the two databases, and must be runnable from a checkout
* whose `.env` carries nothing else. The SQL here is the same shape as the
* analytics summaries, so the weekly read and the dashboard cannot drift
* into different label sets.
* - Every stage carries what it actually measured. Two of the seven agreed
* stages share one label in production today and a third is only a page-view
* proxy; the report states that next to the number instead of inventing a
* figure (see `renderWeeklyMarkdown`).
*
* No `~/env`, no `~/server/database`: the caller owns the connections, the
* tests own an in-memory SQLite, and the script owns the libsql clients.
*/
import { DOWNLOAD_PATH_PREFIX } from "~/lib/download-asset";
/** The one asset key the Nook lands under — `downloadPath("thenook")`. */
export const NOOK_DOWNLOAD_PATH = `${DOWNLOAD_PATH_PREFIX}thenook`;
/** An `execute`-shaped connection: the libsql client and the test adapter both fit. */
export interface MetricsConn {
execute(query: {
sql: string;
args?: any[];
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
}
export interface Connections {
/** freno.me Turso DB — VisitorAnalytics. */
analytics: MetricsConn;
/** the-nook Turso DB — trials, licenses, activations. */
nook: MetricsConn;
}
export type StageKey =
| "visit"
| "download"
| "trial"
| "checkout"
| "purchase"
| "activation";
export interface StageDef {
key: StageKey;
label: string;
/** True when the number is a proxy or a merged label, not a clean stage. */
approximate: boolean;
}
/**
* The seven agreed stages. `download` deliberately carries two of them: the
* door and the file route both write `/api/downloads/thenook`, so a click and
* the DMG it fetched are one label in the data (proved in the report's caveats).
*/
export const STAGES: StageDef[] = [
{ key: "visit", label: "visit (page views)", approximate: false },
{
key: "download",
label: "download click → DMG served (one label)",
approximate: true
},
{ key: "trial", label: "trial started", approximate: false },
{
key: "checkout",
label: "checkout page visit",
approximate: true
},
{ key: "purchase", label: "purchase (licence issued)", approximate: false },
{ key: "activation", label: "activation", approximate: false }
];
export interface FunnelCounts {
visit: number;
download: number;
trial: number;
checkout: number;
purchase: number;
activation: number;
}
export interface CampaignRow {
source: string | null;
medium: string | null;
campaign: string | null;
count: number;
}
/** Traffic-shape facts the report has to state next to the funnel. */
export interface WindowTraffic {
analyticsRows: number;
loopbackRows: number;
/** All-time row counts, so "every row ever" is a checked claim, not a guess. */
allTimeRows: number;
allTimeAddressedRows: number;
allTimeLoopbackRows: number;
downloadRows: number;
downloadLoopbackRows: number;
downloadNonBrowserRows: number;
}
export interface Mover {
key: StageKey;
label: string;
now: number;
before: number;
delta: number;
}
export interface WeeklyReport {
window: { start: string; end: string; days: number };
previous: { start: string; end: string };
counts: FunnelCounts;
previousCounts: FunnelCounts;
campaigns: CampaignRow[];
taggedRows: number;
traffic: WindowTraffic;
mover: Mover | null;
generatedAt: string;
}
/** Loopback addresses — every row the live table has ever held. */
const LOOPBACK = ["127.0.0.1", "::1", "::ffff:127.0.0.1"];
/** SQLite's `datetime()` string, UTC: `YYYY-MM-DD HH:MM:SS`. */
export function sqlTime(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
export interface Windows {
current: { start: string; end: string };
previous: { start: string; end: string };
}
/**
* The current window is `[now - days, now)` and the previous one sits directly
* before it, so a weekly read always compares like with like. Boundaries are
* passed as SQL parameters rather than `datetime('now', …)` so the file a run
* writes is reproducible and the two queries cannot straddle a second.
*/
export function windowsFor(now: Date, days: number): Windows {
const span = days * 24 * 60 * 60 * 1000;
const end = now;
const start = new Date(end.getTime() - span);
const previousEnd = start;
const previousStart = new Date(previousEnd.getTime() - span);
return {
current: { start: sqlTime(start), end: sqlTime(end) },
previous: { start: sqlTime(previousStart), end: sqlTime(previousEnd) }
};
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "bigint") return Number(value);
if (typeof value === "string") return Number(value) || 0;
return 0;
}
async function scalar(
conn: MetricsConn,
sql: string,
args: any[]
): Promise<number> {
const result = await conn.execute({ sql, args });
const row = result.rows[0] as Record<string, unknown> | undefined;
if (!row) return 0;
return toNumber(Object.values(row)[0]);
}
async function countInWindow(
conn: MetricsConn,
sql: string,
start: string,
end: string
): Promise<number> {
return scalar(conn, sql, [start, end]);
}
/** One window's funnel. Two databases, one call. */
export async function readFunnel(
conns: Connections,
window: { start: string; end: string }
): Promise<FunnelCounts> {
const { analytics, nook } = conns;
const { start, end } = window;
const visit = await countInWindow(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND path NOT LIKE '/api/%'`,
start,
end
);
const download = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND path = ?`,
[start, end, NOOK_DOWNLOAD_PATH]
);
const checkout = await countInWindow(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND (path LIKE '/checkout%' OR path LIKE '/nook/checkout%')`,
start,
end
);
// The licence DB stores ISO-8601 (`2026-10-28T20:28:46.806Z`); the analytics
// DB stores `YYYY-MM-DD HH:MM:SS`. `datetime()` normalises both sides so a
// boundary seconds apart cannot include or drop a row on string comparison.
const trial = await scalar(
nook,
`SELECT COUNT(*) AS n FROM trials
WHERE datetime(started_at) >= datetime(?) AND datetime(started_at) < datetime(?)`,
[start, end]
);
const purchase = await scalar(
nook,
`SELECT COUNT(*) AS n FROM licenses
WHERE datetime(created_at) >= datetime(?) AND datetime(created_at) < datetime(?)`,
[start, end]
);
const activation = await scalar(
nook,
`SELECT COUNT(*) AS n FROM activations
WHERE datetime(activated_at) >= datetime(?) AND datetime(activated_at) < datetime(?)`,
[start, end]
);
return {
visit,
download,
trial,
checkout,
purchase,
activation
};
}
/**
* Traffic shape for one window: how much of it came from this host, and how
* much of the download label was automation. Without these two facts the
* numbers read as demand, which they are not.
*/
export async function readTraffic(
conns: Connections,
window: { start: string; end: string }
): Promise<WindowTraffic> {
const { analytics } = conns;
const { start, end } = window;
const placeholders = LOOPBACK.map(() => "?").join(", ");
const allTimeRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics`,
[]
);
const allTimeAddressedRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE ip_address IS NOT NULL`,
[]
);
const allTimeLoopbackRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE ip_address IN (${placeholders})`,
[...LOOPBACK]
);
const analyticsRows = await countInWindow(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?`,
start,
end
);
const loopbackRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND ip_address IN (${placeholders})`,
[start, end, ...LOOPBACK]
);
const downloadRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ? AND path = ?`,
[start, end, NOOK_DOWNLOAD_PATH]
);
const downloadLoopbackRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ? AND path = ?
AND ip_address IN (${placeholders})`,
[start, end, NOOK_DOWNLOAD_PATH, ...LOOPBACK]
);
// Anything whose UA is not a browser engine: curl, Sparkle's updater,
// link-preview crawlers. A real install click arrives as `Mozilla/5.0 …`.
const downloadNonBrowserRows = await scalar(
analytics,
`SELECT COUNT(*) AS n FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ? AND path = ?
AND (user_agent IS NULL OR user_agent NOT LIKE 'Mozilla/%')`,
[start, end, NOOK_DOWNLOAD_PATH]
);
return {
analyticsRows,
loopbackRows,
allTimeRows,
allTimeAddressedRows,
allTimeLoopbackRows,
downloadRows,
downloadLoopbackRows,
downloadNonBrowserRows
};
}
/**
* The campaign dimension, grouped exactly as `getAnalyticsSummary.topCampaigns`
* groups it: top 10 by volume, untagged rows excluded (the untagged bucket is
* a different question and would otherwise swallow the list).
*/
export async function readCampaigns(
analytics: MetricsConn,
window: { start: string; end: string }
): Promise<CampaignRow[]> {
const result = await analytics.execute({
sql: `SELECT utm_source, utm_medium, utm_campaign, COUNT(*) AS count
FROM VisitorAnalytics
WHERE created_at >= ? AND created_at < ?
AND (utm_source IS NOT NULL OR utm_medium IS NOT NULL OR utm_campaign IS NOT NULL)
GROUP BY utm_source, utm_medium, utm_campaign
ORDER BY count DESC
LIMIT 10`,
args: [window.start, window.end]
});
return result.rows.map((raw) => {
const row = raw as Record<string, unknown>;
return {
source: (row.utm_source as string | null) ?? null,
medium: (row.utm_medium as string | null) ?? null,
campaign: (row.utm_campaign as string | null) ?? null,
count: toNumber(row.count)
};
});
}
/**
* "The one thing that moved": the stage with the largest absolute change
* against the previous window. Ties go to the earlier stage, so a week when
* nothing moved returns null rather than a made-up winner.
*/
export function pickMover(
current: FunnelCounts,
previous: FunnelCounts
): Mover | null {
let best: Mover | null = null;
for (const stage of STAGES) {
const delta = current[stage.key] - previous[stage.key];
if (delta === 0) continue;
if (!best || Math.abs(delta) > Math.abs(best.delta)) {
best = {
key: stage.key,
label: stage.label,
now: current[stage.key],
before: previous[stage.key],
delta
};
}
}
return best;
}
export interface ReportOptions {
/** End of the current window; defaults to the wall clock. */
now?: Date;
/** Window length in days; defaults to 7. */
days?: number;
}
/** Read everything `.md` needs, in one call. */
export async function getWeeklyReport(
conns: Connections,
options: ReportOptions = {}
): Promise<WeeklyReport> {
const days = options.days ?? 7;
if (!Number.isFinite(days) || days <= 0) {
throw new Error(`days must be a positive number, got ${days}`);
}
const now = options.now ?? new Date();
const windows = windowsFor(now, days);
const [counts, previousCounts, traffic, campaigns] = await Promise.all([
readFunnel(conns, windows.current),
readFunnel(conns, windows.previous),
readTraffic(conns, windows.current),
readCampaigns(conns.analytics, windows.current)
]);
return {
window: { ...windows.current, days },
previous: windows.previous,
counts,
previousCounts,
campaigns,
taggedRows: campaigns.reduce((sum, row) => sum + row.count, 0),
traffic,
mover: pickMover(counts, previousCounts),
generatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
};
}
function cell(value: string | null): string {
return value === null || value === "" ? "—" : value;
}
function signed(value: number): string {
if (value === 0) return "0";
return value > 0 ? `+${value}` : `−${Math.abs(value)}`;
}
/**
* Render the weekly file. Interpretation lives in the CMO's read; this renderer
* only states what the data is, what moved, and what the numbers cannot say.
* The Gaps section is not boilerplate: each bullet is computed from the window
* it describes, so it empties itself if the underlying hole is ever fixed.
*/
export function renderWeeklyMarkdown(report: WeeklyReport): string {
const { counts, previousCounts, traffic } = report;
const lines: string[] = [];
lines.push("# Weekly funnel snapshot");
lines.push("");
lines.push(
`Window: **${report.window.start} → ${report.window.end} UTC** (${report.window.days} days). ` +
`Previous: ${report.previous.start} → ${report.previous.end} UTC.`
);
lines.push("");
lines.push(
"Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated " +
"weekly, never hand-edited. The analysis is the CMO's."
);
lines.push("");
lines.push("## Funnel");
lines.push("");
lines.push("| Stage | This week | Last week | Change |");
lines.push("|---|---:|---:|---:|");
for (const stage of STAGES) {
const now = counts[stage.key];
const before = previousCounts[stage.key];
const label = stage.approximate ? `${stage.label} †` : stage.label;
lines.push(`| ${label} | ${now} | ${before} | ${signed(now - before)} |`);
}
lines.push("");
lines.push("## Campaigns");
lines.push("");
if (report.campaigns.length === 0) {
lines.push("_No tagged traffic in the window._");
} else {
lines.push(
"Top 10 by volume, untagged excluded — the same grouping " +
"`getAnalyticsSummary.topCampaigns` returns."
);
lines.push("");
lines.push("| Source | Medium | Campaign | Rows |");
lines.push("|---|---|---|---:|");
for (const row of report.campaigns) {
lines.push(
`| ${cell(row.source)} | ${cell(row.medium)} | ${cell(row.campaign)} | ${row.count} |`
);
}
}
lines.push("");
lines.push(
`${report.taggedRows} of ${traffic.analyticsRows} rows in the window carry ` +
`campaign tags; the rest are untagged and therefore unattributed.`
);
lines.push("");
lines.push("## The one thing that moved");
lines.push("");
if (!report.mover) {
lines.push("_Nothing moved: every stage held its previous-week value._");
} else {
const mover = report.mover;
lines.push(
`**${mover.label}: ${mover.before} → ${mover.now} (${signed(mover.delta)}).**`
);
}
lines.push("");
lines.push("## Before acting on these numbers");
lines.push("");
if (traffic.analyticsRows > 0 && traffic.loopbackRows > 0) {
lines.push(
`- **${traffic.loopbackRows} of ${traffic.analyticsRows} rows in the window came from a loopback address** ` +
`(127.0.0.1 / ::1)` +
(traffic.allTimeAddressedRows > 0 &&
traffic.allTimeAddressedRows === traffic.allTimeLoopbackRows
? `; every one of the ${traffic.allTimeAddressedRows} addressed rows the table ` +
`has ever held is loopback (${traffic.allTimeRows - traffic.allTimeAddressedRows} carry no address)`
: `; across the table's history, ${traffic.allTimeLoopbackRows} of ` +
`${traffic.allTimeAddressedRows} addressed rows are loopback`) +
`. Either this host is the only audience, or the app sits behind a local proxy ` +
`and \`getRequestIP\` records the proxy, not the visitor. Either way the address ` +
`identifies nobody — "unique visitors" collapses to one host and per-channel ` +
`reach cannot be read from it.`
);
}
if (traffic.downloadRows > 0) {
lines.push(
`- **The download stage is one label, not two.** The landing-door route and the file ` +
`route that serves the DMG both write \`${NOOK_DOWNLOAD_PATH}\`, so ` +
`"download click → DMG served" cannot be split: one served click writes two rows, ` +
`a click that fails to resolve writes one, and a Sparkle update writes one. ` +
`Of this window's ${traffic.downloadRows} download rows, ` +
`${traffic.downloadLoopbackRows} are loopback and ` +
`${traffic.downloadNonBrowserRows} are not browser user agents (curl, Sparkle, ` +
`link-preview crawlers).`
);
}
lines.push(
`- **Checkout start is not recorded.** \`POST /api/the-nook/checkout\` — the call that ` +
`actually creates the Stripe session — writes nothing to analytics; the only trace is ` +
`the /checkout page view reported above, which is a page, not a started checkout.`
);
lines.push(
`- **There is no host dimension.** \`VisitorAnalytics\` records a path, not the site, so ` +
`the Nook's landing page (\`/\` on nook.freno.me) is the same row as freno.me's root. ` +
`"visit" above is site-wide page views, not Nook visits.`
);
lines.push(
`- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature ` +
`Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.`
);
lines.push("");
lines.push(
"† Proxy or merged label — see *Before acting on these numbers* above."
);
lines.push("");
lines.push("## Sources");
lines.push("");
lines.push("- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.");
lines.push("- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.");
lines.push(`- Generated ${report.generatedAt}.`);
lines.push("");
return lines.join("\n");
}