fix(07-06): launch-gate decisions — KV cron retune to 720/day (the $0 law holds), brand sweep araharah→arah² across user-facing copy, menu button text

This commit is contained in:
araharah-executor
2026-10-25 11:24:03 +08:00
parent 5f4fd8b297
commit dedcdcdbc5
10 changed files with 60 additions and 46 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ const res = await fetch(`https://api.telegram.org/bot${token}/setChatMenuButton`
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
menu_button: { type: "web_app", text: "araharah", web_app: { url: appUrl } },
menu_button: { type: "web_app", text: "arah²", web_app: { url: appUrl } },
}),
});
const body = await res.json();
+1 -1
View File
@@ -52,7 +52,7 @@ import { strings } from "../i18n/strings.ts";
/** The chat card's cadence (D-11/D-13): one card window every 3 minutes;
* the window's first tick is the due tick. */
export const CARD_CADENCE_S = 180;
export const CARD_CADENCE_S = 240;
/** The Worker's cron scan tick — wrangler's crons entry ticks every 60 s. */
const SCAN_TICK_S = 60;
+12 -12
View File
@@ -7,7 +7,7 @@
* copy anywhere in the command tree.
*
* Locales: English default ("en") + Bahasa Melayu ("ms"). Tone is clinical
* + honest (D-01): one sentence for what araharah is, plain statements of
* + honest (D-01): one sentence for what arah² is, plain statements of
* what is stored and what is not — no emoji, no marketing flourishes.
*
* Claim-surface honesty (D-17 / PRIV-01): `privacyStored` names the exact
@@ -19,7 +19,7 @@
*/
export interface BotStrings {
/** D-01: what araharah is, in one sentence. */
/** D-01: what arah² is, in one sentence. */
startIntro: string;
/** PRIV-01: exactly what is stored — must mirror the D-08 claim fields and the TTL. */
privacyStored: string;
@@ -86,18 +86,18 @@ export type Locale = "en" | "ms";
export const strings: Record<Locale, BotStrings> = {
en: {
startIntro:
"araharah — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means.",
"arah² — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means.",
privacyStored:
"What araharah keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone.",
"What arah² keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone.",
privacyNotStored:
"What araharah never keeps: your location, your routes, your trip history, any profile about you.",
"What arah² never keeps: your location, your routes, your trip history, any profile about you.",
helpMenu:
"Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores\n/cari — Search for a destination in the KL metro\nKnown limits: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md",
"Commands:\n/help — How arah² works and what it keeps\n/privacy — What arah² stores and never stores\n/cari — Search for a destination in the KL metro\nKnown limits: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md",
awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.",
shareLocationButton: "Share my location",
pinReceived: "Got it — your starting point is set.",
searchUsage: "/cari <place> — search for a destination in the KL metro.",
searchNoMatch: "Not in the v1 KL area. araharah only knows Kuala Lumpur and the Klang Valley for now.",
searchNoMatch: "Not in the v1 KL area. arah² only knows Kuala Lumpur and the Klang Valley for now.",
searchQueryTooLong: "That query is too long. Send just the place name.",
searchOpenButton: "OPEN IN ARAHARAH",
osmAttribution: "© OpenStreetMap contributors",
@@ -124,18 +124,18 @@ export const strings: Record<Locale, BotStrings> = {
},
ms: {
startIntro:
"araharah — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya.",
"arah² — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya.",
privacyStored:
"Yang araharah simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang.",
"Yang arah² simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang.",
privacyNotStored:
"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.",
"Yang arah² tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.",
helpMenu:
"Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan\n/cari — Cari destinasi di kawasan KL\nHad panduan: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md",
"Arahan:\n/help — Cara arah² berfungsi dan apa yang ia simpan\n/privacy — Apa yang arah² simpan dan tidak pernah simpan\n/cari — Cari destinasi di kawasan KL\nHad panduan: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md",
awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.",
shareLocationButton: "Kongsi lokasi saya",
pinReceived: "Baik — titik mula anda sudah ditetapkan.",
searchUsage: "/cari <tempat> — cari destinasi dalam kawasan KL.",
searchNoMatch: "Bukan dalam kawasan KL v1. araharah hanya tahu Kuala Lumpur dan Lembah Klang buat masa ini.",
searchNoMatch: "Bukan dalam kawasan KL v1. arah² hanya tahu Kuala Lumpur dan Lembah Klang buat masa ini.",
searchQueryTooLong: "Pertanyaan itu terlalu panjang. Hantar nama tempat sahaja.",
searchOpenButton: "OPEN IN ARAHARAH",
osmAttribution: "© OpenStreetMap contributors",
+21 -19
View File
@@ -10,7 +10,9 @@
* Pure-function block (task 1):
* GC1 projection math — T+300 of a 900 s / 10 km snapshot → the 90° left
* carded at register T-01, "667 M", ETA 13:15 · 6.7 KM LEFT
* GC2 cadence table — CARD_CADENCE_S 180; due on the first tick of each
* GC2 cadence table — CARD_CADENCE_S 240 (launch-gate retune, 2025-10-25:
* 2-minute cron ticks x 1 list-op = 720/day, under the 1,000/day free
* ceiling — the $0 law); due on the first tick of each
* 180 s window (nowSec % 180 < 60); non-due ticks carry no card
* GC3 dead trip — elapsed ≥ duration (progress clamped ≥ distanceM) →
* due false, no card: the cron never cards a dead trip
@@ -96,11 +98,11 @@ function envelope({ points, distanceM, durationS, startedAtSec, chatId = 101 })
/* ————— GC1 — the projection math (fixed nowSec, fake street resolver) ————— */
test("GC1 projection: elapsed 180 s of 900 → the 90° left carded at T-01, 2.0 KM, ETA 13:15 · 8.0 KM LEFT", async () => {
test("GC1 projection: elapsed 180 s at the retuned due tick (900 s total) → the 90° left carded at T-01, 2.0 KM, ETA 13:16 · 8.0 KM LEFT", async () => {
const cron = await import("../src/guidance/cron.ts");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail");
assert.equal(card.due, true, "T0 + 180 % 180 === 0 → the window's first tick is due");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 });
const card = cron.projectTurnCards(env, T0 + 240, () => "Jalan Sultan Ismail");
assert.equal(card.due, true, "T0 + 240 % 240 === 0 → the window's first tick is due (the 240 s retune)");
assert.equal(card.glyph, "left", "the first classified turn ahead (progress 2000 < along 4000) is the 90° left");
assert.equal(card.maneuverIdx, 1, "register: depart(00) → left(01)");
const lines = card.caption.split("\n");
@@ -111,20 +113,20 @@ test("GC1 projection: elapsed 180 s of 900 → the 90° left carded at T-01, 2.0
"<b>2.0 KM — TURN LEFT ONTO JALAN SULTAN ISMAIL</b>",
"maneuver line: street-bearing DEFAULT — {dist} — {verb} {STREET} (D-12)",
);
assert.equal(lines[2], "ETA 13:15 · 8.0 KM LEFT", "telemetry line from cardEtaTemplate: remaining 8000 m / 720 s");
assert.equal(lines[2], "ETA 13:16 · 8.0 KM LEFT", "telemetry line from cardEtaTemplate: remaining 8000 m / 720 s");
assert.equal(lines[3], strings.en.cardHonesty, "honesty line verbatim (D-13)");
});
/* ————— GC2 — the cadence table ————— */
test("GC2 cadence: CARD_CADENCE_S 180; due true on the window's first ticks, false after, no card on a non-due tick", async () => {
test("GC2 cadence: CARD_CADENCE_S 240 (*/2-tick retune); due true on the window's first ticks, false after, no card on a non-due tick", async () => {
const cron = await import("../src/guidance/cron.ts");
assert.equal(cron.CARD_CADENCE_S, 180, "the 3-minute cadence is an exported named constant");
assert.equal(cron.CARD_CADENCE_S, 240, "the 4-minute card cadence is an exported named constant (launch-gate retune: */2 cron + 240 s = 720 list-ops/day, under the 1,000 ceiling)");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
const table = [[3600, true], [3659, true], [3660, false], [3779, false], [3780, true]];
const table = [[3600, true], [3659, true], [3660, false], [3779, false], [3840, true], [3899, true], [3900, false], [4079, false], [4080, true]];
for (const [nowSec, expectedDue] of table) {
const card = cron.projectTurnCards(env, nowSec);
assert.equal(card.due, expectedDue, `nowSec ${nowSec}: nowSec % 180 < 60 → ${expectedDue}`);
assert.equal(card.due, expectedDue, `nowSec ${nowSec}: nowSec % 240 < 60 → ${expectedDue}`);
if (!expectedDue) {
assert.equal(card.caption, null, "a non-due tick produces no card");
assert.equal(card.glyph, null, "a non-due tick produces no glyph");
@@ -136,7 +138,7 @@ test("GC2 cadence: CARD_CADENCE_S 180; due true on the window's first ticks, fal
test("GC3 dead trip: elapsed ≥ duration → due false, no card — the cron never cards a dead trip", async () => {
const cron = await import("../src/guidance/cron.ts");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 });
for (const nowSec of [T0 + 900, T0 + 901, T0 + 5000]) {
const card = cron.projectTurnCards(env, nowSec);
assert.equal(card.due, false, `elapsed ${nowSec - T0} s ≥ duration 900 s → the trip would have ended`);
@@ -152,7 +154,7 @@ test("GC3 dead trip: elapsed ≥ duration → due false, no card — the cron ne
test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, no envelope string, D-13 banned words absent", async () => {
const cron = await import("../src/guidance/cron.ts");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail");
const card = cron.projectTurnCards(env, T0 + 240, () => "Jalan Sultan Ismail");
assert.ok(card.caption.length <= 1024, "sendPhoto's caption budget is 1024 chars");
assert.ok(card.caption.includes(strings.en.cardHonesty), "the honesty line rides verbatim");
assert.match(card.caption, /^GUIDE \/\/ T-\d{2}\n/m, "the register is GUIDE // T-{zero-padded nn}");
@@ -164,8 +166,8 @@ test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, n
test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-line anatomy (honest absence, D-03)", async () => {
const cron = await import("../src/guidance/cron.ts");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
const card = cron.projectTurnCards(env, T0 + 180); // no streetAt
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 });
const card = cron.projectTurnCards(env, T0 + 240); // no streetAt
assert.equal(card.due, true);
assert.equal(card.glyph, "left");
const lines = card.caption.split("\n");
@@ -175,8 +177,8 @@ test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-li
test("GC5b street resolver returning null → the bare verb too (a snap miss is honest absence)", async () => {
const cron = await import("../src/guidance/cron.ts");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
const card = cron.projectTurnCards(env, T0 + 180, () => null);
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 });
const card = cron.projectTurnCards(env, T0 + 240, () => null);
const lines = card.caption.split("\n");
assert.equal(lines[1], "<b>2.0 KM — TURN LEFT</b>", "null street → the bare-verb degradation");
});
@@ -186,8 +188,8 @@ test("GC5c a 180° reversal carded as uturn — classifyTurn is the ONE turn aut
const p0 = { lat: KL_LAT, lng: KL_LNG };
const p1 = { lat: KL_LAT, lng: KL_LNG + 2500 / M_PER_DEG_LNG }; // east, bearing 90
const p2 = { lat: KL_LAT, lng: KL_LNG }; // back west, bearing 270 → delta +180 → uturn
const env = envelope({ points: [p0, p1, p2], distanceM: 5000, durationS: 400, startedAtSec: T0 });
const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail");
const env = envelope({ points: [p0, p1, p2], distanceM: 5000, durationS: 400, startedAtSec: T0 + 60 });
const card = cron.projectTurnCards(env, T0 + 240, () => "Jalan Sultan Ismail");
assert.equal(card.glyph, "uturn", "delta 180 ≥ TURN_UTURN_MIN_DEG 150 → uturn");
assert.equal(card.maneuverIdx, 1);
assert.equal(
@@ -434,7 +436,7 @@ async function sentPhotos(calls) {
return out;
}
const TICK_SEC = T0 + 180; // % 180 === 0 → a due tick
const TICK_SEC = T0 + 240; // % 240 === 0 → a due tick (the launch-gate retune)
test("GC-C1 due tick: the alive envelope's card sends to ITS OWN chat, street-bearing from the mini-graph R2 fake", async () => {
const mod = await import("../src/webhook.ts");
+8 -5
View File
@@ -28,10 +28,13 @@
// (docs/ROAD-GRAPH.md is the fork contract). The bucket name is public
// configuration, never a secret.
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
// The repo's FIRST Cron Trigger (06-07, D-11): 60 s scan; card cadence
// decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window. The tick
// is STATELESS (zero KV writes — the free-plan budget law). Local test:
// curl "http://localhost:8787/cdn-cgi/local/scheduled" against `wrangler dev`.
"triggers": { "crons": ["*/1 * * * *"] },
// The repo's FIRST Cron Trigger (06-07, D-11): */2 scan (LAUNCH-GATE decision
// 2025-10-25 — the $0 law holds: 2-min ticks × 1 list-op = 720/day, inside the
// free plan's 1,000/day KV-list ceiling; card cadence is 240 s in code
// (CARD_CADENCE_S). The tick is STATELESS (zero KV writes — the budget law).
// Local test: curl "http://localhost:8787/cdn-cgi/local/scheduled" under
// `wrangler dev`. 90 s granularity is NOT cron-expressible — */2 is the
// coarsest cadence that stays under the ceiling while honoring D-11's spirit.
"triggers": { "crons": ["*/2 * * * *"] },
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
}