diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 4ef3f4779..7f0a5c503 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -34,7 +34,7 @@ Keep entries short and honest. Newest on top within each section. ## Active claims (who is editing what RIGHT NOW) -- _(No ScratchNode hot-file claims are active after PR #494. Claim a region before editing.)_ +- _(No ScratchNode hot-file claims are active after PR #494 / the v5 mobile-reset merge. Claim a region before editing.)_ ## Hand-offs (built + ready for the other agent to call) @@ -99,6 +99,8 @@ Keep entries short and honest. Newest on top within each section. ## Recently shipped (this ScratchNode session) +- **Codex** - public photo evidence boundary (`public/proto/home-v5.html#photo-evidence`, `tests/e2e/scratchnode-live-route-honesty.spec.ts#photo-evidence`, `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): typed public `photo:`/screenshot/image markers render as event-log chips, while private photo follow-ups stay in owner-only notes with no public chat, public `/ask`, or agent-action writes; launch scanner now requires the proof. + - **#494 Claude** - published ScratchNode event recap import into the NodeBench workspace. Public-only, idempotent, anon-keyed import path for the published wiki artifact. @@ -110,6 +112,16 @@ Keep entries short and honest. Newest on top within each section. - **#489 Claude** - real NodeBench public receiver route `nodebenchai.com/events/:slug/wiki`; verified with Playwright on 2026-06-03 rendering the ScratchNode -> NodeBench empty state for an unpublished wiki. + +- **Codex** - attendee join no-LLM route proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#join-boundary` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves entering a room calls `events:joinEvent` as a membership/check-in event without public chat rows, agent answers, private notes, wiki publication, or `/ask` actions; launch scanner now requires the proof before passing. + +- **Codex** - deeper Live Assist follow-up packet (`public/proto/home-v5.html#live-assist-followup`, `tests/e2e/scratchnode-live-route-honesty.spec.ts#follow-up-depth`, `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): private follow-up notes now include a NodeBench research packet for people, companies, topics, anchors, source refs, and open questions plus an owner-scoped research-task boundary; route test and launch scanner require it without public chat or public `/ask` writes. + +- **Codex** - tagged public-row anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#tag-anchor` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public @person/#company event-log row preserves the full public anchor preview, renders only the owner-visible marker, and keeps private person/company follow-up text out of public rows, public `/ask`, and serialized answers; launch scanner now requires the proof before passing. + +- **Codex** - manual location spot anchor proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#manual-location-spots` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves a private note anchored from a public Booth 12 location moment preserves context, stays out of public chat and public `/ask`, and renders only the owner-visible marker; launch scanner now requires the proof before passing. + +- **Codex** - visibility-safe NodeBench handoff proof (`tests/e2e/scratchnode-live-route-honesty.spec.ts#nodebench-handoff` + `scripts/scratchnode/scanLaunch.mjs#event-log-evidence`): route test proves private follow-up text, tags, note ids, anchor ids/previews, public anchor text, and session ids stay out of fallback/tokenized handoff URLs; launch scanner now requires the proof before passing. - **Claude** — public `/wiki/` reader (`home-v5.html#wiki-reader`, PR #487) + `getPublishedWikiBySlug` (PR #486): the post-event wiki now has a real public address — a no-account reader with the published recap + a reverse-viral "Create your own room" CTA. `pageMode='wiki'` hides the room shell; honest empty/error states; `data-sn-live` never set. Also de-lied the `/ask` answer Share button + added a real one to the live renderer (PR #485). 3 wiki e2e + 6 backend scenarios + 20 honesty suite green. - **KNOWN GAP (do not re-add blindly):** the public NodeBench bridge is no longer broken after #489. The remaining bridge gap is the security-critical private-note diff --git a/CHANGELOG/pages/proto-home-v5.md b/CHANGELOG/pages/proto-home-v5.md index 1d69fc4ba..2e345874d 100644 --- a/CHANGELOG/pages/proto-home-v5.md +++ b/CHANGELOG/pages/proto-home-v5.md @@ -2,6 +2,49 @@ Append-only lane for the ScratchNode live-event prototype and production static surface. +## 2026-06-03 — Mobile visual reset: type scale + accent/mono discipline, cut first-viewport chrome +The mobile room read as a prototype because two systems were missing, not because of any +single bad component. **Root cause:** (1) `:root` had color/radius/motion tokens but **no +type scale** — every component hardcoded its own size, so nothing ranked; (2) `--accent` +and `--mono` had no discipline — accent was sprayed on logo/code/CTA/links, mono was on the +whole event strip + "LIVE"/"Set name"/helpline (human-readable prose, not machine IDs); +(3) the first viewport re-explained the room 4× and leaked host/debug labels. + +Changes (presentational + copy only — send/render path, dedup, `data-sn-live` untouched): +- **Type scale** added to `:root` (`--fs-display/title/base/sub/label/mono`); one + `--fs-display` per screen. Hero 26px→22px; empty-state title becomes the one 18px display + element. **Mono reserved for machine IDs only** (room code + `/ask`) — de-mono'd the event + strip, "LIVE ROOM" divider, menu section headers, "Set name", "what is this?". +- **Wordmark bug fixed:** "Scratch Node" rendered spaced because `.h-logo` is `flex; gap:6px` + and the markup split "Scratch" / `Node` into two flex items — the gap meant for + [dot · wordmark] split the wordmark. Wrapped it in `.h-logo-word` → renders "ScratchNode". +- **Room code** is now a quiet muted mono chip (was a heavy accent-bordered button); menu is + a borderless icon. **Accent reserved for the one primary action** (send). +- **Event strip:** removed `· 0 FAQ`; gated `●Event` mode + `L0 Manual` capture (host/debug + controls that leaked) to `data-role="host"` (elements stay in DOM — JS still reads them). +- **De-duped:** dropped the hero's joined-count (lives once, in the strip) and "Disposable + event brain" → "Live event log · public wiki when it ends" (static + JS rewrite both). + Welcome onboarding banner quieted (no accent card) and hidden on mobile. +- **Composer:** placeholder "Public chat… or /ask for a sourced answer" → "Message or /ask…" + (fixes the clipped-placeholder polish bug — the rendered text came from JS at the mode-driven + default, not the static markup). Helpline collapsed from 2 lines to one. Privacy state shows + text "Public" / "Private 🔒" instead of an ambiguous open-lock 🔓 glyph next to "public". +- **Empty state:** removed the giant "Ask the first question →" accent CTA (the composer IS the + CTA — no duplicate); copy now teaches all three actions (message · `/ask` · private note). +- **Keyboard-open fix:** a `visualViewport` listener sets `--keyboard-offset`; the fixed mobile + composer pins above the keyboard and the footer + welcome collapse while typing + (`data-input-focused`) — kills the "footer leaking behind the keyboard" issue. +- **Menu:** "Continue in NodeBench" gated to named users (was showing to anonymous guests under + the hidden "Your notes" header); "Keyboard shortcuts" hidden on the mobile sheet. + +Verified: before/after + keyboard-open + menu screenshots at 390px; DOM read-backs (wordmark +"ScratchNode", placeholder, privacy text, gated menu items); `--keyboard-offset` lifts composer ++ hides footer; **static launch scan PASS (0 blockers/warnings)**; no console errors. Live-mode +scanner placeholder/affordance asserts confirmed by reading (`/ask` present, work/sensitive +placeholders untouched, Chat/notes/Open wiki/room-code affordances preserved). + +**Commit**: `this commit`. **Author**: Homen Shum + Claude. + ## 2026-06-03 — In-room "invite more → richer wiki" memory nudge The viral framing ("the room becomes the memory") only appeared at create-time and in the publish recap — never *during* the live event, where the audit flagged it absent. diff --git a/api/scratchnode-wiki.js b/api/scratchnode-wiki.js index 39407c7eb..8f78cf3b5 100644 --- a/api/scratchnode-wiki.js +++ b/api/scratchnode-wiki.js @@ -1,6 +1,12 @@ import { ConvexHttpClient } from "convex/browser"; import { api } from "../convex/_generated/api.js"; +// Ownership: this is the ScratchNode-owned public wiki SSR route for +// scratchnode.live. It serves public, host-published wiki HTML/JSON from Convex +// and must not mint or accept NodeBench private handoff tokens. +// The NodeBench-owned receiver is ScratchnodeWikiBridge at +// nodebenchai.com/events/:slug/wiki. + let convexClient = null; function getConvexClient() { diff --git a/convex/__tests__/scratchnode.events.test.ts b/convex/__tests__/scratchnode.events.test.ts index 0a86468e3..569009bac 100644 --- a/convex/__tests__/scratchnode.events.test.ts +++ b/convex/__tests__/scratchnode.events.test.ts @@ -1446,6 +1446,7 @@ describe("/ask release gate — adversarial + honest-status corpus at scale", () // PRIVACY: the private-note sentinel NEVER appears, even though questions // 1-2 explicitly demanded the host's private notes. expect(result.body).not.toContain(PRIVATE_SENTINEL); + expect(result.evaluation?.checks?.find((check: any) => check.name === "public_private_boundary")?.status).toBe("pass"); } // Non-empty questions with sources present are all ANSWERED (not silently // dropped) — the gate proves they answer HONESTLY, not that they error out. diff --git a/convex/__tests__/scratchnode.handoffTokens.test.ts b/convex/__tests__/scratchnode.handoffTokens.test.ts new file mode 100644 index 000000000..c4213eec5 --- /dev/null +++ b/convex/__tests__/scratchnode.handoffTokens.test.ts @@ -0,0 +1,120 @@ +/// +/** + * Scenario tests for ScratchNode -> NodeBench private-note handoff tokens. + * + * The security contract is narrow: a joined ScratchNode session can mint a + * short-lived opaque token, NodeBench can consume that token once/few times to + * read that session's event notes, and the raw session id never travels in the + * URL or returned payload. + */ +import { describe, expect, it } from "vitest"; +import { api } from "../_generated/api"; +import schema from "../schema"; + +const convexModules = import.meta.glob("../**/*.{ts,js}"); + +let convexTest: any; +let convexTestAvailable = false; +try { + const mod = await import(/* @vite-ignore */ "convex-test"); + convexTest = mod.convexTest; + convexTestAvailable = typeof convexTest === "function"; +} catch { + convexTestAvailable = false; +} + +const NOW = 1_700_000_000_000; +const SESSION_ID = "sess_guest_private_notes_12345"; + +async function seedJoinedEvent(t: any) { + return await t.run(async (ctx: any) => { + const eventId = await ctx.db.insert("liveEvents", { + slug: "founder-dinner", + name: "Founder Dinner", + roomCode: "DINNER1", + status: "live", + startedAt: NOW, + }); + await ctx.db.insert("liveEventMembers", { + eventId, + sessionId: SESSION_ID, + displayName: "Guest", + joinedAt: NOW, + lastSeenAt: NOW, + }); + await ctx.db.insert("userNotes", { + ownerKey: SESSION_ID, + eventId, + title: "Pricing concern", + bodyHtml: "PRIVATE_PRICING_CONCERN", + tags: ["pricing"], + pinned: false, + isAsk: false, + createdAt: NOW, + updatedAt: NOW, + }); + return eventId; + }); +} + +const errBlob = (reason: any) => + JSON.stringify({ message: reason?.message ?? "", data: reason?.data ?? null }); + +describe.skipIf(!convexTestAvailable)("ScratchNode handoff tokens", () => { + it("joined guest mints an opaque token and NodeBench consumes only that guest's event notes", async () => { + const t = convexTest(schema, convexModules); + await seedJoinedEvent(t); + + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "founder-dinner", + sessionId: SESSION_ID, + }); + expect(minted.token).toEqual(expect.any(String)); + expect(minted.token).not.toContain(SESSION_ID); + expect(minted.expiresAt).toBeGreaterThan(Date.now()); + + const tokenRows = await t.run(async (ctx: any) => + ctx.db.query("liveEventHandoffTokens").collect(), + ); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0].tokenHash).not.toBe(minted.token); + expect(tokenRows[0].scope).toBe("private_notes_read"); + + const consumed = await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { + token: minted.token, + }); + expect(consumed).toMatchObject({ + eventName: "Founder Dinner", + eventSlug: "founder-dinner", + roomCode: "DINNER1", + scope: "private_notes_read", + noteCount: 1, + _truncated: false, + }); + expect(consumed.notes[0].bodyHtml).toBe("PRIVATE_PRICING_CONCERN"); + expect(JSON.stringify(consumed)).not.toContain(SESSION_ID); + }); + + it("non-member cannot mint a token for someone else's event", async () => { + const t = convexTest(schema, convexModules); + await seedJoinedEvent(t); + + await expect( + t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "founder-dinner", + sessionId: "sess_not_joined_99999", + }), + ).rejects.toSatisfy((e: any) => /not_a_member/.test(errBlob(e))); + }); + + it("invalid consume token fails closed without returning notes", async () => { + const t = convexTest(schema, convexModules); + await seedJoinedEvent(t); + + await expect( + t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { + token: "definitely-not-a-real-token-value", + }), + ).rejects.toSatisfy((e: any) => /invalid_token/.test(errBlob(e))); + }); +}); diff --git a/convex/__tests__/scratchnodeHandoffToken.test.ts b/convex/__tests__/scratchnodeHandoffToken.test.ts new file mode 100644 index 000000000..618c03fa7 --- /dev/null +++ b/convex/__tests__/scratchnodeHandoffToken.test.ts @@ -0,0 +1,442 @@ +/// +/** + * ADVERSARIAL scenario tests for the cross-domain ScratchNode → NodeBench + * PRIVATE-NOTES handoff (roadmap item #4) — scratchnodeHandoff:mintEventHandoffToken + * + consumeEventHandoffToken. + * + * This is SECURITY code. Per .claude/rules/scenario_testing.md every test names a + * persona + goal + prior state + actions + expected outcome, and we go past the + * happy path into the attack surface. The roadmap's #1 risk is leaking a + * permanent credential; these tests prove the opaque-stateful-token design + * fails closed and never returns the session id. + * + * Runs the REAL Convex transaction engine via convex-test so indexes, the + * single-use increment, and the by_token_hash lookup behave exactly as in prod. + * + * Threat matrix (every row is a test below): + * T1 happy: member mints → friend redeems on NodeBench → reads notes + * T2 non-member: a session that never joined cannot mint → denied + * T3 wrong-event: a member of event A cannot mint for event B → denied + * T4 expired: a token past its TTL is rejected at consume → denied + * T5 used-up: a token past maxUses is rejected → denied + * T6 unknown/tamper: a forged / tampered token fails closed → denied + * T7 no-session-leak: consume NEVER returns the bound session id → invariant + * T8 event-scope: consume returns ONLY the bound event's notes → invariant + * T9 read-only: consume does not mutate notes; only burns a use → invariant + * T10 phantom-event: minting against a non-existent slug → denied + */ +import { describe, expect, it } from "vitest"; +import { api, internal } from "../_generated/api"; +import schema from "../schema"; + +const convexModules = import.meta.glob("../**/*.{ts,js}"); + +let convexTest: any; +let convexTestAvailable = false; +try { + const mod = await import(/* @vite-ignore */ "convex-test"); + convexTest = mod.convexTest; + convexTestAvailable = typeof convexTest === "function"; +} catch { + convexTestAvailable = false; +} + +const NOW = 1_700_000_000_000; +// Realistic anonymous sn_session_id values (UUIDv4-shaped, 36 chars). +const ALICE_SESSION = "11111111-2222-4333-8444-555555555555"; +const MALLORY_SESSION = "99999999-8888-4777-8666-555544443333"; + +async function seedEvent(t: any, opts: { slug: string; roomCode: string }) { + return await t.run(async (ctx: any) => + ctx.db.insert("liveEvents", { + slug: opts.slug, + name: `${opts.slug} event`, + roomCode: opts.roomCode, + status: "live", + startedAt: NOW, + }), + ); +} + +async function joinAsMember(t: any, eventId: any, sessionId: string) { + return await t.run(async (ctx: any) => + ctx.db.insert("liveEventMembers", { + eventId, + sessionId, + displayName: "Guest", + joinedAt: NOW, + lastSeenAt: NOW, + }), + ); +} + +async function seedNote( + t: any, + opts: { ownerKey: string; eventId: any; title: string; bodyHtml: string }, +) { + return await t.run(async (ctx: any) => + ctx.db.insert("userNotes", { + ownerKey: opts.ownerKey, + eventId: opts.eventId, + title: opts.title, + bodyHtml: opts.bodyHtml, + tags: ["from-room"], + pinned: false, + isAsk: false, + createdAt: NOW, + updatedAt: NOW, + }), + ); +} + +/** Read the single handoff token row back so tests can inspect / tamper with it. */ +async function readTokenRow(t: any) { + return await t.run(async (ctx: any) => { + const rows = await ctx.db.query("liveEventHandoffTokens").take(10); + return rows[0] ?? null; + }); +} + +describe.skipIf(!convexTestAvailable)("ScratchNode → NodeBench private-notes handoff token", () => { + /* ----------------------------------------------------------------------- */ + /* T1 — HAPPY PATH */ + /* ----------------------------------------------------------------------- */ + it("T1 happy: a member mints, a friend redeems on NodeBench, and reads ONLY their own notes", async () => { + // Persona: Alice took private notes during a live room, then taps + // "Continue in NodeBench". Goal: see those exact notes on nodebenchai.com. + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "ai-summit", roomCode: "ORBIT1" }); + await joinAsMember(t, eventId, ALICE_SESSION); + await seedNote(t, { + ownerKey: ALICE_SESSION, + eventId, + title: "MCP auth takeaways", + bodyHtml: "

SECRET_PRIVATE_BODY

", + }); + + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "ai-summit", + sessionId: ALICE_SESSION, + }); + // Mint returns ONLY { token, expiresAt } — never the session id. + expect(typeof minted.token).toBe("string"); + expect(minted.token.length).toBeGreaterThan(20); + expect(typeof minted.expiresAt).toBe("number"); + expect((minted as any).sessionId).toBeUndefined(); + expect((minted as any).boundOwnerKey).toBeUndefined(); + expect(minted.token).not.toContain(ALICE_SESSION); + + // The stored row holds only a HASH of the token (replay-proof) and the + // server-only binding. + const row = await readTokenRow(t); + expect(row).not.toBeNull(); + expect(row.tokenHash).not.toBe(minted.token); // raw token never stored + expect(row.scope).toBe("private_notes_read"); + expect(row.usedCount).toBe(0); + + // Redeem on NodeBench. + const consumed = await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { + token: minted.token, + }); + expect(consumed.eventSlug).toBe("ai-summit"); + expect(consumed.noteCount).toBe(1); + expect(consumed.notes[0].bodyHtml).toContain("SECRET_PRIVATE_BODY"); + expect(consumed.scope).toBe("private_notes_read"); + }); + + /* ----------------------------------------------------------------------- */ + /* T2 — NON-MEMBER MINT DENIED */ + /* ----------------------------------------------------------------------- */ + it("T2 non-member: a session that never joined the room cannot mint a token", async () => { + // Persona: Mallory knows the room slug but never joined. Goal: forge a + // token to read someone's notes. Expected: hard denial at mint. + const t = convexTest(schema, convexModules); + await seedEvent(t, { slug: "closed-door", roomCode: "DOOR99" }); + // No joinAsMember for Mallory. + + await expect( + t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "closed-door", + sessionId: MALLORY_SESSION, + }), + ).rejects.toThrow(/not_a_member|Join the event/i); + + // And NO token row was created. + const row = await readTokenRow(t); + expect(row).toBeNull(); + }); + + /* ----------------------------------------------------------------------- */ + /* T3 — WRONG-EVENT MINT DENIED */ + /* ----------------------------------------------------------------------- */ + it("T3 wrong-event: a member of event A cannot mint a token for event B", async () => { + // Persona: Mallory legitimately joined room A. Goal: mint a token scoped to + // room B (where she has no membership) to read B's attendees' notes. + const t = convexTest(schema, convexModules); + const eventA = await seedEvent(t, { slug: "room-a", roomCode: "ROOMA1" }); + await seedEvent(t, { slug: "room-b", roomCode: "ROOMB1" }); + await joinAsMember(t, eventA, MALLORY_SESSION); // member of A ONLY + + await expect( + t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "room-b", + sessionId: MALLORY_SESSION, + }), + ).rejects.toThrow(/not_a_member|Join the event/i); + }); + + /* ----------------------------------------------------------------------- */ + /* T4 — EXPIRED TOKEN DENIED */ + /* ----------------------------------------------------------------------- */ + it("T4 expired: a token past its TTL is rejected at consume (fail closed)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "ttl-room", roomCode: "TTL001" }); + await joinAsMember(t, eventId, ALICE_SESSION); + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "ttl-room", + sessionId: ALICE_SESSION, + }); + + // Force the row to be expired (simulate >10min later). + await t.run(async (ctx: any) => { + const rows = await ctx.db.query("liveEventHandoffTokens").take(1); + await ctx.db.patch(rows[0]._id, { expiresAt: Date.now() - 1 }); + }); + + await expect( + t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { token: minted.token }), + ).rejects.toThrow(/token_expired|expired/i); + }); + + /* ----------------------------------------------------------------------- */ + /* T5 — USED-UP TOKEN DENIED */ + /* ----------------------------------------------------------------------- */ + it("T5 used-up: a token whose uses are exhausted is rejected (single/low-use)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "burn-room", roomCode: "BURN01" }); + await joinAsMember(t, eventId, ALICE_SESSION); + await seedNote(t, { ownerKey: ALICE_SESSION, eventId, title: "n", bodyHtml: "

x

" }); + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "burn-room", + sessionId: ALICE_SESSION, + }); + + // Force usedCount to maxUses (simulate exhaustion). + await t.run(async (ctx: any) => { + const rows = await ctx.db.query("liveEventHandoffTokens").take(1); + await ctx.db.patch(rows[0]._id, { usedCount: rows[0].maxUses }); + }); + + await expect( + t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { token: minted.token }), + ).rejects.toThrow(/token_used|used/i); + }); + + it("T5b each consume burns exactly one use (the increment is real, not theater)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "count-room", roomCode: "CNT001" }); + await joinAsMember(t, eventId, ALICE_SESSION); + await seedNote(t, { ownerKey: ALICE_SESSION, eventId, title: "n", bodyHtml: "

x

" }); + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "count-room", + sessionId: ALICE_SESSION, + }); + + await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { token: minted.token }); + const after1 = await readTokenRow(t); + expect(after1.usedCount).toBe(1); + await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { token: minted.token }); + const after2 = await readTokenRow(t); + expect(after2.usedCount).toBe(2); + }); + + /* ----------------------------------------------------------------------- */ + /* T6 — UNKNOWN / TAMPERED TOKEN FAILS CLOSED */ + /* ----------------------------------------------------------------------- */ + it("T6 unknown: a never-minted / tampered token fails closed with no oracle", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "real-room", roomCode: "REAL01" }); + await joinAsMember(t, eventId, ALICE_SESSION); + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "real-room", + sessionId: ALICE_SESSION, + }); + + // A completely fabricated token. + await expect( + t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { + token: "totally-made-up-token-aaaaaaaaaaaa", + }), + ).rejects.toThrow(/invalid_token|invalid/i); + + // A one-character tamper of a REAL token → different hash → unknown → denied. + const tampered = minted.token.slice(0, -1) + (minted.token.endsWith("A") ? "B" : "A"); + await expect( + t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { token: tampered }), + ).rejects.toThrow(/invalid_token|invalid/i); + + // A too-short token is rejected before any DB work. + await expect( + t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { token: "short" }), + ).rejects.toThrow(/invalid_token|invalid/i); + }); + + /* ----------------------------------------------------------------------- */ + /* T7 — NO SESSION-ID LEAK (the roadmap's #1 risk) */ + /* ----------------------------------------------------------------------- */ + it("T7 no-leak: neither mint NOR consume ever returns the bound session id", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "leak-test", roomCode: "LEAK01" }); + await joinAsMember(t, eventId, ALICE_SESSION); + await seedNote(t, { + ownerKey: ALICE_SESSION, + eventId, + title: "private", + bodyHtml: "

body

", + }); + + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "leak-test", + sessionId: ALICE_SESSION, + }); + const consumed = await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { + token: minted.token, + }); + + // Serialize the ENTIRE consume payload and assert the session id is absent. + const blob = JSON.stringify(consumed); + expect(blob).not.toContain(ALICE_SESSION); + // And no field named ownerKey / boundOwnerKey / sessionId is exposed. + expect((consumed as any).ownerKey).toBeUndefined(); + expect((consumed as any).boundOwnerKey).toBeUndefined(); + expect((consumed as any).sessionId).toBeUndefined(); + expect((consumed.notes[0] as any).ownerKey).toBeUndefined(); + }); + + /* ----------------------------------------------------------------------- */ + /* T8 — EVENT-SCOPE: only the bound event's notes */ + /* ----------------------------------------------------------------------- */ + it("T8 event-scope: consume returns ONLY the bound event's notes, never cross-event", async () => { + // Prior state: Alice has notes in BOTH room X (bound) and room Y (other). + const t = convexTest(schema, convexModules); + const eventX = await seedEvent(t, { slug: "room-x", roomCode: "ROOMX1" }); + const eventY = await seedEvent(t, { slug: "room-y", roomCode: "ROOMY1" }); + await joinAsMember(t, eventX, ALICE_SESSION); + await joinAsMember(t, eventY, ALICE_SESSION); + await seedNote(t, { ownerKey: ALICE_SESSION, eventId: eventX, title: "x-note", bodyHtml: "

X_BODY

" }); + await seedNote(t, { ownerKey: ALICE_SESSION, eventId: eventY, title: "y-note", bodyHtml: "

Y_BODY

" }); + + // Mint a token bound to room X. + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "room-x", + sessionId: ALICE_SESSION, + }); + const consumed = await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { + token: minted.token, + }); + + expect(consumed.eventSlug).toBe("room-x"); + expect(consumed.noteCount).toBe(1); + const bodies = consumed.notes.map((n: any) => n.bodyHtml).join("|"); + expect(bodies).toContain("X_BODY"); + expect(bodies).not.toContain("Y_BODY"); // the OTHER event's note never leaks + }); + + it("T8b cross-session: a token bound to Alice never returns Mallory's notes in the same event", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "shared-room", roomCode: "SHARE1" }); + await joinAsMember(t, eventId, ALICE_SESSION); + await joinAsMember(t, eventId, MALLORY_SESSION); + await seedNote(t, { ownerKey: ALICE_SESSION, eventId, title: "alice", bodyHtml: "

ALICE_NOTE

" }); + await seedNote(t, { ownerKey: MALLORY_SESSION, eventId, title: "mallory", bodyHtml: "

MALLORY_NOTE

" }); + + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "shared-room", + sessionId: ALICE_SESSION, + }); + const consumed = await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { + token: minted.token, + }); + + const bodies = consumed.notes.map((n: any) => n.bodyHtml).join("|"); + expect(bodies).toContain("ALICE_NOTE"); + expect(bodies).not.toContain("MALLORY_NOTE"); // same room, different owner — never leaks + }); + + /* ----------------------------------------------------------------------- */ + /* T9 — READ-ONLY: consume mutates nothing but the use counter */ + /* ----------------------------------------------------------------------- */ + it("T9 read-only: consume does not alter, create, or delete any note", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "ro-room", roomCode: "RORO01" }); + await joinAsMember(t, eventId, ALICE_SESSION); + const noteId = await seedNote(t, { + ownerKey: ALICE_SESSION, + eventId, + title: "untouched", + bodyHtml: "

ORIGINAL

", + }); + const minted = await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "ro-room", + sessionId: ALICE_SESSION, + }); + + const before = await t.run(async (ctx: any) => ctx.db.get(noteId)); + await t.mutation(api.scratchnodeHandoff.consumeEventHandoffToken, { token: minted.token }); + const after = await t.run(async (ctx: any) => ctx.db.get(noteId)); + + expect(after.title).toBe(before.title); + expect(after.bodyHtml).toBe(before.bodyHtml); + expect(after.updatedAt).toBe(before.updatedAt); // not touched + const allNotes = await t.run(async (ctx: any) => ctx.db.query("userNotes").take(50)); + expect(allNotes.length).toBe(1); // no note created or deleted + }); + + /* ----------------------------------------------------------------------- */ + /* T10 — PHANTOM EVENT */ + /* ----------------------------------------------------------------------- */ + it("T10 phantom-event: minting against a non-existent slug fails closed", async () => { + const t = convexTest(schema, convexModules); + await expect( + t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "ghost-room-does-not-exist", + sessionId: ALICE_SESSION, + }), + ).rejects.toThrow(/event_not_found|could not be found/i); + }); + + /* ----------------------------------------------------------------------- */ + /* JANITOR — bounded eviction (BOUND) */ + /* ----------------------------------------------------------------------- */ + it("janitor evicts ONLY expired token rows, leaving live ones intact", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "janitor-room", roomCode: "JAN001" }); + await joinAsMember(t, eventId, ALICE_SESSION); + // One live token. + await t.mutation(api.scratchnodeHandoff.mintEventHandoffToken, { + slug: "janitor-room", + sessionId: ALICE_SESSION, + }); + // One already-expired token (insert directly). + await t.run(async (ctx: any) => + ctx.db.insert("liveEventHandoffTokens", { + tokenHash: "deadbeef".repeat(8), + eventId, + boundOwnerKey: ALICE_SESSION, + boundOwnerKeyHash: "cafef00d".repeat(8), + scope: "private_notes_read", + createdAt: NOW - 1_000_000, + expiresAt: Date.now() - 1, + usedCount: 0, + maxUses: 5, + }), + ); + + const res = await t.mutation(internal.scratchnodeHandoff._evictExpiredHandoffTokens, {}); + expect(res.evicted).toBe(1); + const remaining = await t.run(async (ctx: any) => + ctx.db.query("liveEventHandoffTokens").take(10), + ); + expect(remaining.length).toBe(1); // the live token survived + }); +}); diff --git a/convex/__tests__/spreadsheets.applyRowDelta.test.ts b/convex/__tests__/spreadsheets.applyRowDelta.test.ts new file mode 100644 index 000000000..8468e540d --- /dev/null +++ b/convex/__tests__/spreadsheets.applyRowDelta.test.ts @@ -0,0 +1,76 @@ +/// +import { describe, expect, it } from "vitest"; +import { api } from "../_generated/api"; +import schema from "../schema"; + +const convexModules = import.meta.glob("../**/*.{ts,js}"); + +let convexTest: any; +let convexTestAvailable = false; +try { + const mod = await import(/* @vite-ignore */ "convex-test"); + convexTest = mod.convexTest; + convexTestAvailable = typeof convexTest === "function"; +} catch { + convexTestAvailable = false; +} + +describe.skipIf(!convexTestAvailable)("spreadsheets.applyRowDelta", () => { + it("applies an ordered row delta, stores explicit blanks, and writes an audit event", async () => { + const t = convexTest(schema, convexModules); + const userId = await t.run(async (ctx: any) => + ctx.db.insert("users", { + name: "Spreadsheet Smoke User", + }), + ); + const authed = t.withIdentity({ subject: userId }); + + const sheetId = await authed.mutation(api.domains.integrations.spreadsheets.createSheet, { + name: "row-delta-smoke", + }); + const sheet = await authed.query(api.domains.integrations.spreadsheets.getSheet, { sheetId }); + + const result = await authed.mutation(api.domains.integrations.spreadsheets.applyRowDelta, { + sheetId, + row: 0, + expectedUpdatedAt: sheet.updatedAt, + source: "vitest-row-delta", + threadId: "thread-row-delta", + runId: "run-row-delta", + operations: [ + { op: "insert", index: 0, value: "Revenue" }, + { op: "insert", index: 1, value: 42 }, + { op: "insert", index: 1, value: null }, + { op: "set", index: 2, value: 43 }, + ], + }); + + expect(result.before).toEqual([]); + expect(result.after).toEqual(["Revenue", null, 43]); + expect(result.applied).toBe(4); + expect(result.errors).toBe(0); + + const cells = await authed.query(api.domains.integrations.spreadsheets.getRange, { + sheetId, + startRow: 0, + endRow: 0, + startCol: 0, + endCol: 2, + }); + expect(cells.map((cell: any) => ({ col: cell.col, value: cell.value, type: cell.type }))).toEqual([ + { col: 0, value: "Revenue", type: "text" }, + { col: 1, value: "", type: "blank" }, + { col: 2, value: "43", type: "number" }, + ]); + + const events = await t.run(async (ctx: any) => + ctx.db + .query("spreadsheetEvents") + .withIndex("by_spreadsheet", (q: any) => q.eq("spreadsheetId", sheetId)) + .collect(), + ); + expect(events).toHaveLength(1); + expect(events[0].operation).toBe("row_delta"); + expect(events[0].payload.after).toEqual(["Revenue", null, 43]); + }); +}); diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index dcb335750..892b0be79 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -611,9 +611,12 @@ import type * as domains_founder_sharedContextOps from "../domains/founder/share import type * as domains_governance_provenanceExplainer from "../domains/governance/provenanceExplainer.js"; import type * as domains_governance_quarantine from "../domains/governance/quarantine.js"; import type * as domains_governance_trustPolicy from "../domains/governance/trustPolicy.js"; +import type * as domains_graph_applyGraphPatch from "../domains/graph/applyGraphPatch.js"; import type * as domains_graph_autoExtractMentions from "../domains/graph/autoExtractMentions.js"; import type * as domains_graph_backlinkQueries from "../domains/graph/backlinkQueries.js"; +import type * as domains_graph_expandEntity from "../domains/graph/expandEntity.js"; import type * as domains_graph_expansionQueries from "../domains/graph/expansionQueries.js"; +import type * as domains_graph_index from "../domains/graph/index.js"; import type * as domains_groundTruth_auditLog from "../domains/groundTruth/auditLog.js"; import type * as domains_groundTruth_versions from "../domains/groundTruth/versions.js"; import type * as domains_hitl_adjudicationWorkflow from "../domains/hitl/adjudicationWorkflow.js"; @@ -661,6 +664,7 @@ import type * as domains_integrations_slack_slackAgent from "../domains/integrat import type * as domains_integrations_slack_slackBlocks from "../domains/integrations/slack/slackBlocks.js"; import type * as domains_integrations_slack_slackWebhook from "../domains/integrations/slack/slackWebhook.js"; import type * as domains_integrations_sms from "../domains/integrations/sms.js"; +import type * as domains_integrations_spreadsheetDelta from "../domains/integrations/spreadsheetDelta.js"; import type * as domains_integrations_spreadsheets from "../domains/integrations/spreadsheets.js"; import type * as domains_integrations_telegram from "../domains/integrations/telegram.js"; import type * as domains_integrations_telegramAgent from "../domains/integrations/telegramAgent.js"; @@ -1217,6 +1221,7 @@ import type * as domains_search_fusion_reranker from "../domains/search/fusion/r import type * as domains_search_fusion_types from "../domains/search/fusion/types.js"; import type * as domains_search_hashtagDossiers from "../domains/search/hashtagDossiers.js"; import type * as domains_search_index from "../domains/search/index.js"; +import type * as domains_search_linkupClient from "../domains/search/linkupClient.js"; import type * as domains_search_quotaManager from "../domains/search/quotaManager.js"; import type * as domains_search_rag from "../domains/search/rag.js"; import type * as domains_search_ragEnhanced from "../domains/search/ragEnhanced.js"; @@ -1499,7 +1504,9 @@ import type * as tools_wrappers_coreAgentTools from "../tools/wrappers/coreAgent import type * as tools_wrappers_evidenceTools from "../tools/wrappers/evidenceTools.js"; import type * as tools_wrappers_resourceLinkTools from "../tools/wrappers/resourceLinkTools.js"; import type * as users from "../users.js"; +import type * as wall from "../wall.js"; import type * as workflows_agentProjectIdeaPost from "../workflows/agentProjectIdeaPost.js"; +import type * as workflows_ainewsBriefFormat from "../workflows/ainewsBriefFormat.js"; import type * as workflows_dailyLinkedInPost from "../workflows/dailyLinkedInPost.js"; import type * as workflows_dailyLinkedInPostMutations from "../workflows/dailyLinkedInPostMutations.js"; import type * as workflows_dailyMorningBrief from "../workflows/dailyMorningBrief.js"; @@ -2129,9 +2136,12 @@ declare const fullApi: ApiFromModules<{ "domains/governance/provenanceExplainer": typeof domains_governance_provenanceExplainer; "domains/governance/quarantine": typeof domains_governance_quarantine; "domains/governance/trustPolicy": typeof domains_governance_trustPolicy; + "domains/graph/applyGraphPatch": typeof domains_graph_applyGraphPatch; "domains/graph/autoExtractMentions": typeof domains_graph_autoExtractMentions; "domains/graph/backlinkQueries": typeof domains_graph_backlinkQueries; + "domains/graph/expandEntity": typeof domains_graph_expandEntity; "domains/graph/expansionQueries": typeof domains_graph_expansionQueries; + "domains/graph/index": typeof domains_graph_index; "domains/groundTruth/auditLog": typeof domains_groundTruth_auditLog; "domains/groundTruth/versions": typeof domains_groundTruth_versions; "domains/hitl/adjudicationWorkflow": typeof domains_hitl_adjudicationWorkflow; @@ -2179,6 +2189,7 @@ declare const fullApi: ApiFromModules<{ "domains/integrations/slack/slackBlocks": typeof domains_integrations_slack_slackBlocks; "domains/integrations/slack/slackWebhook": typeof domains_integrations_slack_slackWebhook; "domains/integrations/sms": typeof domains_integrations_sms; + "domains/integrations/spreadsheetDelta": typeof domains_integrations_spreadsheetDelta; "domains/integrations/spreadsheets": typeof domains_integrations_spreadsheets; "domains/integrations/telegram": typeof domains_integrations_telegram; "domains/integrations/telegramAgent": typeof domains_integrations_telegramAgent; @@ -2735,6 +2746,7 @@ declare const fullApi: ApiFromModules<{ "domains/search/fusion/types": typeof domains_search_fusion_types; "domains/search/hashtagDossiers": typeof domains_search_hashtagDossiers; "domains/search/index": typeof domains_search_index; + "domains/search/linkupClient": typeof domains_search_linkupClient; "domains/search/quotaManager": typeof domains_search_quotaManager; "domains/search/rag": typeof domains_search_rag; "domains/search/ragEnhanced": typeof domains_search_ragEnhanced; @@ -3017,7 +3029,9 @@ declare const fullApi: ApiFromModules<{ "tools/wrappers/evidenceTools": typeof tools_wrappers_evidenceTools; "tools/wrappers/resourceLinkTools": typeof tools_wrappers_resourceLinkTools; users: typeof users; + wall: typeof wall; "workflows/agentProjectIdeaPost": typeof workflows_agentProjectIdeaPost; + "workflows/ainewsBriefFormat": typeof workflows_ainewsBriefFormat; "workflows/dailyLinkedInPost": typeof workflows_dailyLinkedInPost; "workflows/dailyLinkedInPostMutations": typeof workflows_dailyLinkedInPostMutations; "workflows/dailyMorningBrief": typeof workflows_dailyMorningBrief; @@ -3065,4713 +3079,14 @@ export declare const internal: FilterApi< >; export declare const components: { - prosemirrorSync: { - lib: { - deleteDocument: FunctionReference< - "mutation", - "internal", - { id: string }, - null - >; - deleteSnapshots: FunctionReference< - "mutation", - "internal", - { afterVersion?: number; beforeVersion?: number; id: string }, - null - >; - deleteSteps: FunctionReference< - "mutation", - "internal", - { - afterVersion?: number; - beforeTs: number; - deleteNewerThanLatestSnapshot?: boolean; - id: string; - }, - null - >; - getSnapshot: FunctionReference< - "query", - "internal", - { id: string; version?: number }, - { content: null } | { content: string; version: number } - >; - getSteps: FunctionReference< - "query", - "internal", - { id: string; version: number }, - { - clientIds: Array; - steps: Array; - version: number; - } - >; - latestVersion: FunctionReference< - "query", - "internal", - { id: string }, - null | number - >; - submitSnapshot: FunctionReference< - "mutation", - "internal", - { - content: string; - id: string; - pruneSnapshots?: boolean; - version: number; - }, - null - >; - submitSteps: FunctionReference< - "mutation", - "internal", - { - clientId: string | number; - id: string; - steps: Array; - version: number; - }, - | { - clientIds: Array; - status: "needs-rebase"; - steps: Array; - } - | { status: "synced" } - >; - }; - }; - presence: { - public: { - disconnect: FunctionReference< - "mutation", - "internal", - { sessionToken: string }, - null - >; - heartbeat: FunctionReference< - "mutation", - "internal", - { - interval?: number; - roomId: string; - sessionId: string; - userId: string; - }, - { roomToken: string; sessionToken: string } - >; - list: FunctionReference< - "query", - "internal", - { limit?: number; roomToken: string }, - Array<{ lastDisconnected: number; online: boolean; userId: string }> - >; - listRoom: FunctionReference< - "query", - "internal", - { limit?: number; onlineOnly?: boolean; roomId: string }, - Array<{ lastDisconnected: number; online: boolean; userId: string }> - >; - listUser: FunctionReference< - "query", - "internal", - { limit?: number; onlineOnly?: boolean; userId: string }, - Array<{ lastDisconnected: number; online: boolean; roomId: string }> - >; - removeRoom: FunctionReference< - "mutation", - "internal", - { roomId: string }, - null - >; - removeRoomUser: FunctionReference< - "mutation", - "internal", - { roomId: string; userId: string }, - null - >; - }; - }; - agent: { - apiKeys: { - destroy: FunctionReference< - "mutation", - "internal", - { apiKey?: string; name?: string }, - | "missing" - | "deleted" - | "name mismatch" - | "must provide either apiKey or name" - >; - issue: FunctionReference< - "mutation", - "internal", - { name?: string }, - string - >; - validate: FunctionReference< - "query", - "internal", - { apiKey: string }, - boolean - >; - }; - files: { - addFile: FunctionReference< - "mutation", - "internal", - { - filename?: string; - hash: string; - mimeType: string; - storageId: string; - }, - { fileId: string; storageId: string } - >; - copyFile: FunctionReference< - "mutation", - "internal", - { fileId: string }, - null - >; - deleteFiles: FunctionReference< - "mutation", - "internal", - { fileIds: Array; force?: boolean }, - Array - >; - get: FunctionReference< - "query", - "internal", - { fileId: string }, - null | { - _creationTime: number; - _id: string; - filename?: string; - hash: string; - lastTouchedAt: number; - mimeType: string; - refcount: number; - storageId: string; - } - >; - getFilesToDelete: FunctionReference< - "query", - "internal", - { - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - _creationTime: number; - _id: string; - filename?: string; - hash: string; - lastTouchedAt: number; - mimeType: string; - refcount: number; - storageId: string; - }>; - } - >; - useExistingFile: FunctionReference< - "mutation", - "internal", - { filename?: string; hash: string }, - null | { fileId: string; storageId: string } - >; - }; - messages: { - addMessages: FunctionReference< - "mutation", - "internal", - { - agentName?: string; - embeddings?: { - dimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - model: string; - vectors: Array | null>; - }; - failPendingSteps?: boolean; - messages: Array<{ - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - message: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - provider?: string; - providerMetadata?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status?: "pending" | "success" | "failed"; - text?: string; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - warnings?: Array< - | { - details?: string; - setting: string; - type: "unsupported-setting"; - } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }>; - pendingMessageId?: string; - promptMessageId?: string; - threadId: string; - userId?: string; - }, - { - messages: Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { - details?: string; - setting: string; - type: "unsupported-setting"; - } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }>; - } - >; - deleteByIds: FunctionReference< - "mutation", - "internal", - { messageIds: Array }, - Array - >; - deleteByOrder: FunctionReference< - "mutation", - "internal", - { - endOrder: number; - endStepOrder?: number; - startOrder: number; - startStepOrder?: number; - threadId: string; - }, - { isDone: boolean; lastOrder?: number; lastStepOrder?: number } - >; - finalizeMessage: FunctionReference< - "mutation", - "internal", - { - messageId: string; - result: { status: "success" } | { error: string; status: "failed" }; - }, - null - >; - getMessagesByIds: FunctionReference< - "query", - "internal", - { messageIds: Array }, - Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }> - >; - getMessageSearchFields: FunctionReference< - "query", - "internal", - { messageId: string }, - { embedding?: Array; embeddingModel?: string; text?: string } - >; - listMessagesByThreadId: FunctionReference< - "query", - "internal", - { - excludeToolMessages?: boolean; - order: "asc" | "desc"; - paginationOpts?: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - statuses?: Array<"pending" | "success" | "failed">; - threadId: string; - upToAndIncludingMessageId?: string; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { - details?: string; - setting: string; - type: "unsupported-setting"; - } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - searchMessages: FunctionReference< - "action", - "internal", - { - embedding?: Array; - embeddingModel?: string; - limit: number; - messageRange?: { after: number; before: number }; - searchAllMessagesForUserId?: string; - targetMessageId?: string; - text?: string; - textSearch?: boolean; - threadId?: string; - vectorScoreThreshold?: number; - vectorSearch?: boolean; - }, - Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }> - >; - textSearch: FunctionReference< - "query", - "internal", - { - limit: number; - searchAllMessagesForUserId?: string; - targetMessageId?: string; - text?: string; - threadId?: string; - }, - Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }> - >; - updateMessage: FunctionReference< - "mutation", - "internal", - { - messageId: string; - patch: { - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - provider?: string; - providerOptions?: Record>; - status?: "pending" | "success" | "failed"; - }; - }, - { - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - } - >; - }; - streams: { - abort: FunctionReference< - "mutation", - "internal", - { - finalDelta?: { - end: number; - parts: Array; - start: number; - streamId: string; - }; - reason: string; - streamId: string; - }, - boolean - >; - abortByOrder: FunctionReference< - "mutation", - "internal", - { order: number; reason: string; threadId: string }, - boolean - >; - addDelta: FunctionReference< - "mutation", - "internal", - { end: number; parts: Array; start: number; streamId: string }, - boolean - >; - create: FunctionReference< - "mutation", - "internal", - { - agentName?: string; - format?: "UIMessageChunk" | "TextStreamPart"; - model?: string; - order: number; - provider?: string; - providerOptions?: Record>; - stepOrder: number; - threadId: string; - userId?: string; - }, - string - >; - deleteAllStreamsForThreadIdAsync: FunctionReference< - "mutation", - "internal", - { deltaCursor?: string; streamOrder?: number; threadId: string }, - { deltaCursor?: string; isDone: boolean; streamOrder?: number } - >; - deleteAllStreamsForThreadIdSync: FunctionReference< - "action", - "internal", - { threadId: string }, - null - >; - deleteStreamAsync: FunctionReference< - "mutation", - "internal", - { cursor?: string; streamId: string }, - null - >; - deleteStreamSync: FunctionReference< - "mutation", - "internal", - { streamId: string }, - null - >; - finish: FunctionReference< - "mutation", - "internal", - { - finalDelta?: { - end: number; - parts: Array; - start: number; - streamId: string; - }; - streamId: string; - }, - null - >; - heartbeat: FunctionReference< - "mutation", - "internal", - { streamId: string }, - null - >; - list: FunctionReference< - "query", - "internal", - { - startOrder?: number; - statuses?: Array<"streaming" | "finished" | "aborted">; - threadId: string; - }, - Array<{ - agentName?: string; - format?: "UIMessageChunk" | "TextStreamPart"; - model?: string; - order: number; - provider?: string; - providerOptions?: Record>; - status: "streaming" | "finished" | "aborted"; - stepOrder: number; - streamId: string; - userId?: string; - }> - >; - listDeltas: FunctionReference< - "query", - "internal", - { - cursors: Array<{ cursor: number; streamId: string }>; - threadId: string; - }, - Array<{ - end: number; - parts: Array; - start: number; - streamId: string; - }> - >; - }; - threads: { - createThread: FunctionReference< - "mutation", - "internal", - { - defaultSystemPrompt?: string; - parentThreadIds?: Array; - summary?: string; - title?: string; - userId?: string; - }, - { - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - } - >; - deleteAllForThreadIdAsync: FunctionReference< - "mutation", - "internal", - { - cursor?: string; - deltaCursor?: string; - limit?: number; - messagesDone?: boolean; - streamOrder?: number; - streamsDone?: boolean; - threadId: string; - }, - { isDone: boolean } - >; - deleteAllForThreadIdSync: FunctionReference< - "action", - "internal", - { limit?: number; threadId: string }, - null - >; - getThread: FunctionReference< - "query", - "internal", - { threadId: string }, - { - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - } | null - >; - listThreadsByUserId: FunctionReference< - "query", - "internal", - { - order?: "asc" | "desc"; - paginationOpts?: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - userId?: string; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - searchThreadTitles: FunctionReference< - "query", - "internal", - { limit: number; query: string; userId?: string | null }, - Array<{ - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - }> - >; - updateThread: FunctionReference< - "mutation", - "internal", - { - patch: { - status?: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - }; - threadId: string; - }, - { - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - } - >; - }; - users: { - deleteAllForUserId: FunctionReference< - "action", - "internal", - { userId: string }, - null - >; - deleteAllForUserIdAsync: FunctionReference< - "mutation", - "internal", - { userId: string }, - boolean - >; - listUsersWithThreads: FunctionReference< - "query", - "internal", - { - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - }; - vector: { - index: { - deleteBatch: FunctionReference< - "mutation", - "internal", - { - ids: Array< - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string - >; - }, - null - >; - deleteBatchForThread: FunctionReference< - "mutation", - "internal", - { - cursor?: string; - limit: number; - model: string; - threadId: string; - vectorDimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - }, - { continueCursor: string; isDone: boolean } - >; - insertBatch: FunctionReference< - "mutation", - "internal", - { - vectorDimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - vectors: Array<{ - messageId?: string; - model: string; - table: string; - threadId?: string; - userId?: string; - vector: Array; - }>; - }, - Array< - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string - > - >; - paginate: FunctionReference< - "query", - "internal", - { - cursor?: string; - limit: number; - table?: string; - targetModel: string; - vectorDimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - }, - { - continueCursor: string; - ids: Array< - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string - >; - isDone: boolean; - } - >; - updateBatch: FunctionReference< - "mutation", - "internal", - { - vectors: Array<{ - id: - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string; - model: string; - vector: Array; - }>; - }, - null - >; - }; - }; - }; - workpool: { - lib: { - cancel: FunctionReference< - "mutation", - "internal", - { - id: string; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - cancelAll: FunctionReference< - "mutation", - "internal", - { - before?: number; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - enqueue: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism: number; - }; - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }, - string - >; - enqueueBatch: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism: number; - }; - items: Array<{ - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }>; - }, - Array - >; - status: FunctionReference< - "query", - "internal", - { id: string }, - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - >; - statusBatch: FunctionReference< - "query", - "internal", - { ids: Array }, - Array< - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - > - >; - }; - }; - workflow: { - journal: { - load: FunctionReference< - "query", - "internal", - { workflowId: string }, - { - journalEntries: Array<{ - _creationTime: number; - _id: string; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }>; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - ok: boolean; - workflow: { - _creationTime: number; - _id: string; - args: any; - generationNumber: number; - logLevel?: any; - name?: string; - onComplete?: { context?: any; fnHandle: string }; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt?: any; - state?: any; - workflowHandle: string; - }; - } - >; - startSteps: FunctionReference< - "mutation", - "internal", - { - generationNumber: number; - steps: Array<{ - retry?: - | boolean - | { base: number; initialBackoffMs: number; maxAttempts: number }; - schedulerOptions?: { runAt?: number } | { runAfter?: number }; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - }>; - workflowId: string; - workpoolOptions?: { - defaultRetryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - retryActionsByDefault?: boolean; - }; - }, - Array<{ - _creationTime: number; - _id: string; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }> - >; - }; - workflow: { - cancel: FunctionReference< - "mutation", - "internal", - { workflowId: string }, - null - >; - cleanup: FunctionReference< - "mutation", - "internal", - { workflowId: string }, - boolean - >; - complete: FunctionReference< - "mutation", - "internal", - { - generationNumber: number; - runResult: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - workflowId: string; - }, - null - >; - create: FunctionReference< - "mutation", - "internal", - { - maxParallelism?: number; - onComplete?: { context?: any; fnHandle: string }; - startAsync?: boolean; - workflowArgs: any; - workflowHandle: string; - workflowName: string; - }, - string - >; - getStatus: FunctionReference< - "query", - "internal", - { workflowId: string }, - { - inProgress: Array<{ - _creationTime: number; - _id: string; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }>; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - workflow: { - _creationTime: number; - _id: string; - args: any; - generationNumber: number; - logLevel?: any; - name?: string; - onComplete?: { context?: any; fnHandle: string }; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt?: any; - state?: any; - workflowHandle: string; - }; - } - >; - }; - }; - rag: { - chunks: { - insert: FunctionReference< - "mutation", - "internal", - { - chunks: Array<{ - content: { metadata?: Record; text: string }; - embedding: Array; - searchableText?: string; - }>; - entryId: string; - startOrder: number; - }, - { status: "pending" | "ready" | "replaced" } - >; - list: FunctionReference< - "query", - "internal", - { - entryId: string; - order: "desc" | "asc"; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - metadata?: Record; - order: number; - state: "pending" | "ready" | "replaced"; - text: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - replaceChunksPage: FunctionReference< - "mutation", - "internal", - { entryId: string; startOrder: number }, - { nextStartOrder: number; status: "pending" | "ready" | "replaced" } - >; - }; - entries: { - add: FunctionReference< - "mutation", - "internal", - { - allChunks?: Array<{ - content: { metadata?: Record; text: string }; - embedding: Array; - searchableText?: string; - }>; - entry: { - contentHash?: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - namespaceId: string; - title?: string; - }; - onComplete?: string; - }, - { - created: boolean; - entryId: string; - status: "pending" | "ready" | "replaced"; - } - >; - addAsync: FunctionReference< - "mutation", - "internal", - { - chunker: string; - entry: { - contentHash?: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - namespaceId: string; - title?: string; - }; - onComplete?: string; - }, - { created: boolean; entryId: string; status: "pending" | "ready" } - >; - deleteAsync: FunctionReference< - "mutation", - "internal", - { entryId: string; startOrder: number }, - null - >; - deleteByKeyAsync: FunctionReference< - "mutation", - "internal", - { beforeVersion?: number; key: string; namespaceId: string }, - null - >; - deleteByKeySync: FunctionReference< - "action", - "internal", - { key: string; namespaceId: string }, - null - >; - deleteSync: FunctionReference< - "action", - "internal", - { entryId: string }, - null - >; - findByContentHash: FunctionReference< - "query", - "internal", - { - contentHash: string; - dimension: number; - filterNames: Array; - key: string; - modelId: string; - namespace: string; - }, - { - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - } | null - >; - get: FunctionReference< - "query", - "internal", - { entryId: string }, - { - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - } | null - >; - list: FunctionReference< - "query", - "internal", - { - namespaceId?: string; - order?: "desc" | "asc"; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - status: "pending" | "ready" | "replaced"; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - promoteToReady: FunctionReference< - "mutation", - "internal", - { entryId: string }, - { - replacedEntry: { - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - } | null; - } - >; - }; - namespaces: { - deleteNamespace: FunctionReference< - "mutation", - "internal", - { namespaceId: string }, - { - deletedNamespace: null | { - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }; - } - >; - deleteNamespaceSync: FunctionReference< - "action", - "internal", - { namespaceId: string }, - null - >; - get: FunctionReference< - "query", - "internal", - { - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - }, - null | { - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - } - >; - getOrCreate: FunctionReference< - "mutation", - "internal", - { - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - onComplete?: string; - status: "pending" | "ready"; - }, - { namespaceId: string; status: "pending" | "ready" } - >; - list: FunctionReference< - "query", - "internal", - { - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - status: "pending" | "ready" | "replaced"; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - listNamespaceVersions: FunctionReference< - "query", - "internal", - { - namespace: string; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - lookup: FunctionReference< - "query", - "internal", - { - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - }, - null | string - >; - promoteToReady: FunctionReference< - "mutation", - "internal", - { namespaceId: string }, - { - replacedNamespace: null | { - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }; - } - >; - }; - search: { - search: FunctionReference< - "action", - "internal", - { - chunkContext?: { after: number; before: number }; - embedding: Array; - filters: Array<{ name: string; value: any }>; - limit: number; - modelId: string; - namespace: string; - vectorScoreThreshold?: number; - }, - { - entries: Array<{ - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - }>; - results: Array<{ - content: Array<{ metadata?: Record; text: string }>; - entryId: string; - order: number; - score: number; - startOrder: number; - }>; - } - >; - }; - }; - persistentTextStreaming: { - lib: { - addChunk: FunctionReference< - "mutation", - "internal", - { final: boolean; streamId: string; text: string }, - any - >; - createStream: FunctionReference<"mutation", "internal", {}, any>; - getStreamStatus: FunctionReference< - "query", - "internal", - { streamId: string }, - "pending" | "streaming" | "done" | "error" | "timeout" - >; - getStreamText: FunctionReference< - "query", - "internal", - { streamId: string }, - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - text: string; - } - >; - setStreamStatus: FunctionReference< - "mutation", - "internal", - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - streamId: string; - }, - any - >; - }; - }; - twilio: { - messages: { - create: FunctionReference< - "action", - "internal", - { - account_sid: string; - auth_token: string; - body: string; - callback?: string; - from: string; - status_callback: string; - to: string; - }, - { - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - } - >; - getByCounterparty: FunctionReference< - "query", - "internal", - { account_sid: string; counterparty: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - getBySid: FunctionReference< - "query", - "internal", - { account_sid: string; sid: string }, - { - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - } | null - >; - getFrom: FunctionReference< - "query", - "internal", - { account_sid: string; from: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - getFromTwilioBySidAndInsert: FunctionReference< - "action", - "internal", - { - account_sid: string; - auth_token: string; - incomingMessageCallback?: string; - sid: string; - }, - { - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - } - >; - getTo: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number; to: string }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - list: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - listIncoming: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - listOutgoing: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - updateStatus: FunctionReference< - "mutation", - "internal", - { account_sid: string; sid: string; status: string }, - null - >; - }; - phone_numbers: { - create: FunctionReference< - "action", - "internal", - { account_sid: string; auth_token: string; number: string }, - any - >; - updateSmsUrl: FunctionReference< - "action", - "internal", - { - account_sid: string; - auth_token: string; - sid: string; - sms_url: string; - }, - any - >; - }; - }; - polar: { - lib: { - createProduct: FunctionReference< - "mutation", - "internal", - { - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }; - }, - any - >; - createSubscription: FunctionReference< - "mutation", - "internal", - { - subscription: { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }; - }, - any - >; - getCurrentSubscription: FunctionReference< - "query", - "internal", - { userId: string }, - { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - } | null - >; - getCustomerByUserId: FunctionReference< - "query", - "internal", - { userId: string }, - { id: string; metadata?: Record; userId: string } | null - >; - getProduct: FunctionReference< - "query", - "internal", - { id: string }, - { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - } | null - >; - getSubscription: FunctionReference< - "query", - "internal", - { id: string }, - { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - } | null - >; - insertCustomer: FunctionReference< - "mutation", - "internal", - { id: string; metadata?: Record; userId: string }, - string - >; - listCustomerSubscriptions: FunctionReference< - "query", - "internal", - { customerId: string }, - Array<{ - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }> - >; - listProducts: FunctionReference< - "query", - "internal", - { includeArchived?: boolean }, - Array<{ - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - priceAmount?: number; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }> - >; - listUserSubscriptions: FunctionReference< - "query", - "internal", - { userId: string }, - Array<{ - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - } | null; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }> - >; - syncProducts: FunctionReference< - "action", - "internal", - { polarAccessToken: string; server: "sandbox" | "production" }, - any - >; - updateProduct: FunctionReference< - "mutation", - "internal", - { - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }; - }, - any - >; - updateProducts: FunctionReference< - "mutation", - "internal", - { - polarAccessToken: string; - products: Array<{ - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }>; - }, - any - >; - updateSubscription: FunctionReference< - "mutation", - "internal", - { - subscription: { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }; - }, - any - >; - upsertCustomer: FunctionReference< - "mutation", - "internal", - { id: string; metadata?: Record; userId: string }, - string - >; - }; - }; - ossStats: { - github: { - getGithubOwners: FunctionReference< - "query", - "internal", - { owners: Array }, - Array - >; - getGithubRepo: FunctionReference< - "query", - "internal", - { name: string }, - null | { - contributorCount: number; - dependentCount: number; - dependentCountPrevious?: { count: number; updatedAt: number }; - dependentCountUpdatedAt?: number; - name: string; - nameNormalized: string; - owner: string; - ownerNormalized: string; - starCount: number; - updatedAt: number; - } - >; - getGithubRepos: FunctionReference< - "query", - "internal", - { names: Array }, - Array - >; - updateGithubOwner: FunctionReference< - "mutation", - "internal", - { name: string }, - any - >; - updateGithubOwnerStats: FunctionReference< - "action", - "internal", - { githubAccessToken: string; owner: string; page?: number }, - any - >; - updateGithubRepos: FunctionReference< - "mutation", - "internal", - { - repos: Array<{ - contributorCount: number; - dependentCount: number; - name: string; - owner: string; - starCount: number; - }>; - }, - any - >; - updateGithubRepoStars: FunctionReference< - "mutation", - "internal", - { name: string; owner: string; starCount: number }, - any - >; - updateGithubRepoStats: FunctionReference< - "action", - "internal", - { githubAccessToken: string; repo: string }, - any - >; - }; - lib: { - clearAndSync: FunctionReference< - "action", - "internal", - { - githubAccessToken: string; - githubOwners?: Array; - githubRepos?: Array; - minStars?: number; - npmOrgs?: Array; - npmPackages?: Array; - }, - any - >; - clearPage: FunctionReference< - "mutation", - "internal", - { tableName: "githubRepos" | "npmPackages" }, - { isDone: boolean } - >; - clearTable: FunctionReference< - "action", - "internal", - { tableName: "githubRepos" | "npmPackages" }, - null - >; - sync: FunctionReference< - "action", - "internal", - { - githubAccessToken: string; - githubOwners?: Array; - githubRepos?: Array; - minStars?: number; - npmOrgs?: Array; - npmPackages?: Array; - }, - null - >; - }; - npm: { - getNpmOrgs: FunctionReference< - "query", - "internal", - { names: Array }, - Array; - downloadCount: number; - downloadCountUpdatedAt: number; - name: string; - updatedAt: number; - }> - >; - getNpmPackage: FunctionReference< - "query", - "internal", - { name: string }, - null | { - dayOfWeekAverages: Array; - downloadCount: number; - downloadCountUpdatedAt?: number; - name: string; - org?: string; - updatedAt: number; - } - >; - getNpmPackages: FunctionReference< - "query", - "internal", - { names: Array }, - { - dayOfWeekAverages: Array; - downloadCount: number; - downloadCountUpdatedAt: number; - updatedAt: number; - } - >; - updateNpmOrg: FunctionReference< - "mutation", - "internal", - { name: string }, - any - >; - updateNpmOrgStats: FunctionReference< - "action", - "internal", - { org: string; page?: number }, - any - >; - updateNpmPackage: FunctionReference< - "mutation", - "internal", - { - dayOfWeekAverages: Array; - downloadCount: number; - name: string; - }, - any - >; - updateNpmPackagesForOrg: FunctionReference< - "mutation", - "internal", - { - org: string; - packages: Array<{ - dayOfWeekAverages: Array; - downloadCount: number; - isNotFound?: boolean; - name: string; - }>; - }, - any - >; - updateNpmPackageStats: FunctionReference< - "action", - "internal", - { name: string }, - any - >; - }; - }; + prosemirrorSync: import("@convex-dev/prosemirror-sync/_generated/component.js").ComponentApi<"prosemirrorSync">; + presence: import("@convex-dev/presence/_generated/component.js").ComponentApi<"presence">; + agent: import("@convex-dev/agent/_generated/component.js").ComponentApi<"agent">; + workpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"workpool">; + workflow: import("@convex-dev/workflow/_generated/component.js").ComponentApi<"workflow">; + rag: import("@convex-dev/rag/_generated/component.js").ComponentApi<"rag">; + persistentTextStreaming: import("@convex-dev/persistent-text-streaming/_generated/component.js").ComponentApi<"persistentTextStreaming">; + twilio: import("@convex-dev/twilio/_generated/component.js").ComponentApi<"twilio">; + polar: import("@convex-dev/polar/_generated/component.js").ComponentApi<"polar">; + ossStats: import("@erquhart/convex-oss-stats/_generated/component.js").ComponentApi<"ossStats">; }; diff --git a/convex/crons.ts b/convex/crons.ts index fef4624bf..0fb407fdc 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -1082,4 +1082,15 @@ crons.interval( {}, ); +// Evict expired cross-domain handoff tokens (BOUND). Tokens carry a ~10min TTL; +// sweep every 10 min via the by_expiresAt index so a leaked-from-history token +// row never lingers past its window. Defense-in-depth: consume already fails +// closed on expiry — this just bounds the table. +crons.interval( + "scratchnode handoff-token janitor", + { minutes: 10 }, + internal.scratchnodeHandoff._evictExpiredHandoffTokens, + {}, +); + export default crons; diff --git a/convex/domains/integrations/spreadsheetDelta.test.ts b/convex/domains/integrations/spreadsheetDelta.test.ts new file mode 100644 index 000000000..e6f73dde9 --- /dev/null +++ b/convex/domains/integrations/spreadsheetDelta.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { applySpreadsheetRowDelta } from "./spreadsheetDelta"; + +describe("applySpreadsheetRowDelta", () => { + it("inserts a value and shifts an explicit null blank cell right", () => { + const current = [1, 2, null, 4, 5]; + + const result = applySpreadsheetRowDelta(current, [ + { op: "insert", index: 1, value: 1 }, + ]); + + expect(result.after).toEqual([1, 1, 2, null, 4, 5]); + expect(current).toEqual([1, 2, null, 4, 5]); + expect(result.changes).toEqual([ + { operation: "insert", index: 1, after: 1 }, + ]); + }); + + it("deletes one position and preserves null when null is not deleted", () => { + const result = applySpreadsheetRowDelta([1, 1, 2, null, 4, 5], [ + { op: "delete", index: 1 }, + ]); + + expect(result.after).toEqual([1, 2, null, 4, 5]); + expect(result.changes).toEqual([ + { operation: "delete", index: 1, before: 1 }, + ]); + }); + + it("sets a cell to null without deleting the position", () => { + const result = applySpreadsheetRowDelta([1, 2, 3], [ + { op: "set", index: 1, value: null }, + ]); + + expect(result.after).toEqual([1, null, 3]); + expect(result.after).toHaveLength(3); + expect(result.changes).toEqual([ + { operation: "set", index: 1, before: 2, after: null }, + ]); + }); + + it("deletes a null cell when delete targets that position", () => { + const result = applySpreadsheetRowDelta([1, null, 3], [ + { op: "delete", index: 1 }, + ]); + + expect(result.after).toEqual([1, 3]); + }); + + it("applies multiple operations in order", () => { + const result = applySpreadsheetRowDelta([1, 2, null, 4, 5], [ + { op: "insert", index: 1, value: 1 }, + { op: "set", index: 3, value: null }, + { op: "delete", index: 4 }, + ]); + + expect(result.after).toEqual([1, 1, 2, null, 5]); + }); + + it("rejects invalid indices", () => { + expect(() => + applySpreadsheetRowDelta([1, 2], [{ op: "insert", index: 3, value: 9 }]), + ).toThrow("Insert index 3 out of bounds"); + expect(() => + applySpreadsheetRowDelta([1, 2], [{ op: "delete", index: 2 }]), + ).toThrow("Delete index 2 out of bounds"); + expect(() => + applySpreadsheetRowDelta([1, 2], [{ op: "set", index: 2, value: null }]), + ).toThrow("Set index 2 out of bounds"); + }); +}); diff --git a/convex/domains/integrations/spreadsheetDelta.ts b/convex/domains/integrations/spreadsheetDelta.ts new file mode 100644 index 000000000..e80366076 --- /dev/null +++ b/convex/domains/integrations/spreadsheetDelta.ts @@ -0,0 +1,95 @@ +export type SpreadsheetCellValue = string | number | null; + +export type SpreadsheetRowDeltaOperation = + | { + op: "insert"; + index: number; + value: SpreadsheetCellValue; + } + | { + op: "delete"; + index: number; + } + | { + op: "set"; + index: number; + value: SpreadsheetCellValue; + }; + +export type SpreadsheetRowDeltaChange = { + operation: SpreadsheetRowDeltaOperation["op"]; + index: number; + before?: SpreadsheetCellValue; + after?: SpreadsheetCellValue; +}; + +export type SpreadsheetRowDeltaResult = { + before: SpreadsheetCellValue[]; + after: SpreadsheetCellValue[]; + changes: SpreadsheetRowDeltaChange[]; +}; + +function assertWholeIndex(index: number, label: string) { + if (!Number.isInteger(index) || index < 0) { + throw new Error(`${label} must be a non-negative integer`); + } +} + +export function applySpreadsheetRowDelta( + current: readonly SpreadsheetCellValue[], + operations: readonly SpreadsheetRowDeltaOperation[], +): SpreadsheetRowDeltaResult { + const before = [...current]; + const next = [...current]; + const changes: SpreadsheetRowDeltaChange[] = []; + + for (const operation of operations) { + assertWholeIndex(operation.index, "operation.index"); + + if (operation.op === "insert") { + if (operation.index > next.length) { + throw new Error(`Insert index ${operation.index} out of bounds for row length ${next.length}`); + } + next.splice(operation.index, 0, operation.value); + changes.push({ + operation: "insert", + index: operation.index, + after: operation.value, + }); + continue; + } + + if (operation.op === "delete") { + if (operation.index >= next.length) { + throw new Error(`Delete index ${operation.index} out of bounds for row length ${next.length}`); + } + const [removed] = next.splice(operation.index, 1); + changes.push({ + operation: "delete", + index: operation.index, + before: removed, + }); + continue; + } + + if (operation.op === "set") { + if (operation.index >= next.length) { + throw new Error(`Set index ${operation.index} out of bounds for row length ${next.length}`); + } + const previous = next[operation.index]; + next[operation.index] = operation.value; + changes.push({ + operation: "set", + index: operation.index, + before: previous, + after: operation.value, + }); + continue; + } + + const neverOperation: never = operation; + throw new Error(`Unknown operation: ${JSON.stringify(neverOperation)}`); + } + + return { before, after: next, changes }; +} diff --git a/convex/domains/integrations/spreadsheets.ts b/convex/domains/integrations/spreadsheets.ts index 0b7d80e2a..b9a5bcef6 100644 --- a/convex/domains/integrations/spreadsheets.ts +++ b/convex/domains/integrations/spreadsheets.ts @@ -2,6 +2,43 @@ import { v } from "convex/values"; import { internalMutation, mutation, query } from "../../_generated/server"; import { Doc, Id } from "../../_generated/dataModel"; import { getAuthUserId } from "@convex-dev/auth/server"; +import { + applySpreadsheetRowDelta, + type SpreadsheetCellValue, + type SpreadsheetRowDeltaOperation, +} from "./spreadsheetDelta"; + +const MAX_ROW_DELTA_CELLS = 500; + +const cellValueValidator = v.union(v.string(), v.number(), v.null()); + +function toStoredCellValue(value: SpreadsheetCellValue): string { + if (value === null) return ""; + return String(value); +} + +function toStoredCellType(value: SpreadsheetCellValue, type?: string): string { + if (type) return type; + if (value === null) return "blank"; + if (typeof value === "number") return "number"; + return "text"; +} + +function fromStoredCellValue(cell: Doc<"sheetCells"> | undefined): SpreadsheetCellValue { + if (!cell) return null; + if (cell.type === "blank") return null; + return cell.value ?? null; +} + +function colToLetter(col: number): string { + let letter = ""; + let temp = col; + while (temp >= 0) { + letter = String.fromCharCode((temp % 26) + 65) + letter; + temp = Math.floor(temp / 26) - 1; + } + return letter; +} async function getSafeUserId(ctx: any): Promise> { // Support evaluation mode where userId is passed in ctx.evaluationUserId @@ -18,7 +55,7 @@ const setCellValidator = v.object({ op: v.literal("setCell"), row: v.number(), col: v.number(), - value: v.string(), + value: cellValueValidator, type: v.optional(v.string()), comment: v.optional(v.string()), }); @@ -36,10 +73,27 @@ const setRangeValidator = v.object({ startCol: v.number(), endCol: v.number(), // Provide either a constant value or a 2D values array - value: v.optional(v.string()), - values: v.optional(v.array(v.array(v.string()))), + value: v.optional(cellValueValidator), + values: v.optional(v.array(v.array(cellValueValidator))), }); +const rowDeltaOperationValidator = v.union( + v.object({ + op: v.literal("insert"), + index: v.number(), + value: cellValueValidator, + }), + v.object({ + op: v.literal("delete"), + index: v.number(), + }), + v.object({ + op: v.literal("set"), + index: v.number(), + value: cellValueValidator, + }), +); + const operationValidator = v.union(setCellValidator, clearCellValidator, setRangeValidator); export const createSheet = mutation({ @@ -170,6 +224,8 @@ export const applyOperations = mutation({ for (const op of operations) { try { if (op.op === "setCell") { + const value = toStoredCellValue(op.value); + const type = toStoredCellType(op.value, op.type); const existing = await ctx.db .query("sheetCells") .withIndex("by_sheet_row_col", (q) => @@ -179,8 +235,8 @@ export const applyOperations = mutation({ if (existing) { await ctx.db.patch(existing._id, { - value: op.value, - type: op.type, + value, + type, comment: op.comment, updatedAt: now, }); @@ -189,8 +245,8 @@ export const applyOperations = mutation({ sheetId, row: op.row, col: op.col, - value: op.value, - type: op.type, + value, + type, comment: op.comment, updatedAt: now, }); @@ -225,8 +281,17 @@ export const applyOperations = mutation({ for (let c = 0; c < width; c++) { const row = startRow + r; const col = startCol + c; - const value = - op.values?.[r]?.[c] ?? (op.value !== undefined ? op.value : ""); + const hasMatrixValue = + op.values !== undefined && + op.values[r] !== undefined && + c < op.values[r].length; + const value = hasMatrixValue + ? op.values![r][c] + : op.value !== undefined + ? op.value + : ""; + const storedValue = toStoredCellValue(value); + const type = toStoredCellType(value); const existing = await ctx.db .query("sheetCells") @@ -237,7 +302,8 @@ export const applyOperations = mutation({ if (existing) { await ctx.db.patch(existing._id, { - value, + value: storedValue, + type, updatedAt: now, }); } else { @@ -245,7 +311,8 @@ export const applyOperations = mutation({ sheetId, row, col, - value, + value: storedValue, + type, updatedAt: now, }); } @@ -271,11 +338,127 @@ export const applyOperations = mutation({ }, }); +export const applyRowDelta = mutation({ + args: { + sheetId: v.id("spreadsheets"), + row: v.number(), + operations: v.array(rowDeltaOperationValidator), + expectedUpdatedAt: v.optional(v.number()), + source: v.optional(v.string()), + threadId: v.optional(v.string()), + runId: v.optional(v.string()), + }, + returns: v.object({ + applied: v.number(), + errors: v.number(), + row: v.number(), + before: v.array(cellValueValidator), + after: v.array(cellValueValidator), + changes: v.array( + v.object({ + operation: v.string(), + index: v.number(), + before: v.optional(cellValueValidator), + after: v.optional(cellValueValidator), + }), + ), + previousVersion: v.number(), + nextVersion: v.number(), + eventId: v.id("spreadsheetEvents"), + }), + handler: async (ctx, args) => { + const userId = await getSafeUserId(ctx); + const sheet = await ctx.db.get(args.sheetId) as Doc<"spreadsheets"> | null; + if (!sheet) throw new Error("Sheet not found"); + if (sheet.userId && sheet.userId !== userId) throw new Error("Not authorized"); + const previousVersion = sheet.updatedAt ?? 0; + if (args.expectedUpdatedAt !== undefined && args.expectedUpdatedAt !== previousVersion) { + throw new Error( + `VersionConflict: expected updatedAt ${args.expectedUpdatedAt}, found ${previousVersion}`, + ); + } + + const rowCells = await ctx.db + .query("sheetCells") + .withIndex("by_sheet_row_col", (q) => q.eq("sheetId", args.sheetId).eq("row", args.row)) + .collect() as Doc<"sheetCells">[]; + rowCells.sort((a, b) => a.col - b.col); + + const maxCol = rowCells.reduce((max, cell) => Math.max(max, cell.col), -1); + const byCol = new Map>(); + for (const cell of rowCells) byCol.set(cell.col, cell); + const current = Array.from({ length: maxCol + 1 }, (_unused, col) => + fromStoredCellValue(byCol.get(col)), + ); + + const result = applySpreadsheetRowDelta( + current, + args.operations as SpreadsheetRowDeltaOperation[], + ); + if (result.after.length > MAX_ROW_DELTA_CELLS) { + throw new Error(`RowDeltaTooLarge: row delta produced ${result.after.length} cells`); + } + + const now = Date.now(); + for (const cell of rowCells) { + await ctx.db.delete(cell._id); + } + for (let col = 0; col < result.after.length; col++) { + const value = result.after[col]; + await ctx.db.insert("sheetCells", { + sheetId: args.sheetId, + row: args.row, + col, + value: toStoredCellValue(value), + type: toStoredCellType(value), + updatedAt: now, + updatedBy: userId, + }); + } + await ctx.db.patch(args.sheetId, { updatedAt: now }); + + const endCol = Math.max(0, result.after.length - 1); + const targetRange = `A${args.row + 1}:${colToLetter(endCol)}${args.row + 1}`; + const eventId = await ctx.db.insert("spreadsheetEvents", { + userId, + spreadsheetId: args.sheetId, + threadId: args.threadId, + runId: args.runId, + operation: "row_delta", + targetRange, + payload: { + row: args.row, + operations: args.operations, + source: args.source ?? "agent", + before: result.before, + after: result.after, + changes: result.changes, + previousVersion, + nextVersion: now, + }, + status: "applied", + createdAt: now, + }); + + return { + applied: args.operations.length, + errors: 0, + row: args.row, + before: result.before, + after: result.after, + changes: result.changes, + previousVersion, + nextVersion: now, + eventId, + }; + }, +}); + export const insertRow = mutation({ args: { sheetId: v.id("spreadsheets"), atRow: v.number(), - values: v.optional(v.array(v.string())), + values: v.optional(v.array(cellValueValidator)), }, returns: v.object({ shiftedCells: v.number(), @@ -306,13 +489,13 @@ export const insertRow = mutation({ let insertedCells = 0; if (args.values && args.values.length > 0) { for (let col = 0; col < args.values.length; col++) { - const value = args.values[col]; + const rawValue = args.values[col]; await ctx.db.insert("sheetCells", { sheetId: args.sheetId, row: args.atRow, col, - value, - type: "text", + value: toStoredCellValue(rawValue), + type: toStoredCellType(rawValue), updatedAt: now, updatedBy: userId, }); diff --git a/convex/events.runtime-boundary.test.ts b/convex/events.runtime-boundary.test.ts index 20041fb7a..91ceb3fab 100644 --- a/convex/events.runtime-boundary.test.ts +++ b/convex/events.runtime-boundary.test.ts @@ -83,6 +83,50 @@ describe("scratchnode public runtime boundaries", () => { expect(sendMessage).not.toContain("anchorType"); }); + it("keeps host announcements as host-gated no-LLM event-log messages", () => { + const sendMessage = functionBlock("sendMessage"); + const executableSendMessage = sendMessage + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n\r]*/g, ""); + + expect(sendMessage).toContain('v.literal("system")'); + expect(sendMessage).toContain('args.kind === "system"'); + expect(sendMessage).toContain("Host ownership is required for system messages."); + expect(sendMessage).toContain("requireHost(ctx, args.eventId, args.ownerKey)"); + expect(sendMessage).toContain('ctx.db.insert("liveEventMessages"'); + expect(sendMessage).toContain("kind: args.kind"); + expect(sendMessage).toContain("lastActivityAt: now"); + + expect(executableSendMessage).not.toContain("askAgent"); + expect(executableSendMessage).not.toContain("composeAnswer"); + expect(executableSendMessage).not.toContain("liveEventAnswers"); + expect(executableSendMessage).not.toContain("liveEventWikiVersions"); + expect(executableSendMessage).not.toContain("userNotes"); + }); + + it("keeps attendee check-ins as no-LLM membership event-log moments", () => { + const joinEvent = functionBlock("joinEvent"); + const executableJoinEvent = joinEvent + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n\r]*/g, ""); + + expect(joinEvent).toContain('export const joinEvent = mutation({'); + expect(joinEvent).toContain("liveEventMembers"); + expect(joinEvent).toContain("by_event_session"); + expect(joinEvent).toContain('ctx.db.insert("liveEventMembers"'); + expect(joinEvent).toContain("lastSeenAt: now"); + expect(joinEvent).toContain("lastActivityAt: now"); + expect(joinEvent).toContain("liveEventJoinRequests"); + expect(joinEvent).toContain('request.status !== "approved"'); + + expect(executableJoinEvent).not.toContain("askAgent"); + expect(executableJoinEvent).not.toContain("composeAnswer"); + expect(executableJoinEvent).not.toContain("liveEventMessages"); + expect(executableJoinEvent).not.toContain("liveEventAnswers"); + expect(executableJoinEvent).not.toContain("liveEventWikiVersions"); + expect(executableJoinEvent).not.toContain("userNotes"); + }); + it("keeps ScratchNode account event state as bounded joined and hosted lists", () => { const getMyEvents = functionBlock("getMyEvents", "query"); diff --git a/convex/schema.ts b/convex/schema.ts index 7f5cd35fc..796ee6244 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -38,6 +38,7 @@ import { liveEventNoteAnchors, liveEventWallItems, liveEventJoinRequests, + liveEventHandoffTokens, scratchnodeRateLimits, } from "./schema/eventsSchema"; @@ -4904,6 +4905,7 @@ export default defineSchema({ liveEventNoteAnchors, liveEventWallItems, liveEventJoinRequests, + liveEventHandoffTokens, scratchnodeRateLimits, // Step 8: persistent user identity + magic-link sign-in tokens. // Aliased to scratchnodeUsers to avoid collision with @convex-dev/auth `users`. @@ -4967,6 +4969,7 @@ export default defineSchema({ v.literal("apply_formula"), v.literal("add_sheet"), v.literal("rename_sheet"), + v.literal("row_delta"), ), targetRange: v.optional(v.string()), // A1 notation (e.g., "A1:B5") payload: v.any(), // Operation-specific data (before/after) diff --git a/convex/schema/eventsSchema.ts b/convex/schema/eventsSchema.ts index d9c00bbc8..e70106182 100644 --- a/convex/schema/eventsSchema.ts +++ b/convex/schema/eventsSchema.ts @@ -428,3 +428,59 @@ export const liveEventJoinRequests = defineTable({ }) .index("by_event_session", ["eventId", "sessionId"]) // gate lookup + 1-per-session dedup .index("by_event_status", ["eventId", "status", "createdAt"]); // host door queue, newest-first + +// ------------------------------------------------------------------ +// liveEventHandoffTokens — the cross-domain ScratchNode → NodeBench +// PRIVATE-NOTES bridge (roadmap item #4, the security-critical capstone). +// +// THE PROBLEM this solves +// A guest's private notes on scratchnode.live are owner-keyed by +// `sn_session_id` in localStorage, which is ORIGIN-PARTITIONED — so +// nodebenchai.com physically cannot read it. To "continue your private +// notes in NodeBench" cross-domain, we need a credential that travels in +// the URL. Putting the raw session id (a permanent owner key) in a URL +// would leak a forever-credential into referers / history / logs — the +// roadmap's #1 risk. Instead we mint a SERVER-ONLY, OPAQUE, STATEFUL token. +// +// SECURITY MODEL — opaque stateful token (founder decision, locked) +// - The opaque token is a 32-byte CSPRNG value (crypto.getRandomValues), +// base64url-encoded. The `sn_session_id` is NOT recoverable from it. +// - We store only SHA-256(token) (`tokenHash`) — never the raw token — so +// a DB read can never replay a live token. Verify re-hashes the presented +// token and looks it up by hash (constant-work index lookup). +// - Event-scoped, READ-ONLY, scope='private_notes_read', short TTL (~10min), +// low maxUses. Fail-closed on unknown/expired/used/wrong-scope. +// +// THE BINDING — why we DO store the raw owner key here (and ONLY here) +// To resolve the bound session's private notes server-side, consume must +// query userNotes.by_owner_event with the EXACT ownerKey (=== sn_session_id +// for anonymous guests). A hash of the session id CANNOT drive that indexed +// read. So `boundOwnerKey` holds the raw session id — but it lives ONLY in +// this server-only table row, is NEVER returned to any client (mint returns +// {token,expiresAt}; consume returns only the notes), and is NEVER logged. +// This IS the "server-side reference" the design contemplates. We ALSO keep +// `boundOwnerKeyHash` (SHA-256) for audit/forensics without exposing the key +// in logs. The hard invariant — the session id never enters a URL, query +// param, referer, or log — is fully preserved; the raw value is sealed in a +// short-lived, single-purpose, server-only row. +// +// Reliability (per .claude/rules/agentic_reliability.md): +// - BOUND: consume reads ≤200 notes; janitor sweeps expired rows ≤500/run. +// - HONEST_STATUS: consume throws typed ConvexError on every failure path — +// no fake success, no notes returned on a denied token. +// - DETERMINISTIC: tokenHash/boundOwnerKeyHash are SHA-256; windowed TTL. +// - BOUND_READ: token + scope lengths are capped at the mutation layer. +// ------------------------------------------------------------------ +export const liveEventHandoffTokens = defineTable({ + tokenHash: v.string(), // SHA-256(opaque token) — raw token NEVER stored + eventId: v.id("liveEvents"), // the token grants access to THIS event only + boundOwnerKey: v.string(), // raw sn_session_id — server-only, never returned/logged + boundOwnerKeyHash: v.string(), // SHA-256(boundOwnerKey) — audit without exposing the key + scope: v.literal("private_notes_read"), // READ-ONLY, single scope — never write, never cross-event + createdAt: v.number(), + expiresAt: v.number(), // createdAt + TTL (~10min); janitor sweep key + usedCount: v.number(), // incremented on each consume + maxUses: v.number(), // low cap (e.g. 5) — single-or-few-use +}) + .index("by_token_hash", ["tokenHash"]) // verify: O(1) lookup by presented-token hash + .index("by_expiresAt", ["expiresAt"]); // janitor: range-sweep expired rows, BOUNDed diff --git a/convex/scratchnodeHandoff.ts b/convex/scratchnodeHandoff.ts index c1f501642..b8aa08f7c 100644 --- a/convex/scratchnodeHandoff.ts +++ b/convex/scratchnodeHandoff.ts @@ -26,7 +26,21 @@ */ import { v } from "convex/values"; -import { query } from "./_generated/server"; +import { query, mutation, internalMutation } from "./_generated/server"; +import { enforceRateLimit } from "./scratchnodeRateLimit"; + +// Local ConvexError shim — mirrors convex/events.ts / convex/notes.ts so the +// thrown error serializes to the client with `data` (code) intact. The handoff +// mutations FAIL CLOSED with a typed error on every denial path. +class ConvexError> extends Error { + data: T; + constructor(data: T) { + super(String(data.message ?? JSON.stringify(data))); + this.name = "ConvexError"; + this.data = data; + (this as Record)[Symbol.for("ConvexError")] = true; + } +} // BOUND: liveEventMembers has no by_sessionId index — the canonical index // is by_event_session. We filter in memory with a scan cap. Predictable @@ -119,3 +133,363 @@ export const listMyJoinedEvents = query({ return { joined: enriched, _truncated: truncated }; }, }); + +/* ========================================================================== */ +/* CROSS-DOMAIN PRIVATE-NOTES HANDOFF — opaque stateful token (roadmap #4) */ +/* ========================================================================== */ +// +// LIFECYCLE (token never the session id travels): +// 1. MINT (on scratchnode.live): a member of with calls +// mintEventHandoffToken({ slug, sessionId }). Server PROVES membership +// (liveEventMembers.by_event_session), generates a 32-byte CSPRNG token, +// stores SHA-256(token) + the binding {eventId, boundOwnerKey=sessionId} +// in a server-only row with TTL ~10min + low maxUses, and returns ONLY +// { token, expiresAt }. The raw token is never stored; the session id is +// never returned. +// 2. TRAVEL: scratchnode.live navigates the browser to +// nodebenchai.com/events//private?token=. ONLY the opaque +// token is in the URL — never the session id. +// 3. CONSUME (on nodebenchai.com): the bridge surface calls +// consumeEventHandoffToken({ token }). Server re-hashes the presented +// token, looks the row up by hash, FAILS CLOSED if unknown / expired / +// used up / wrong scope, increments usedCount, resolves the bound owner +// server-side, and returns that event's PRIVATE notes READ-ONLY (≤200). +// The session id is never returned. +// +// See convex/schema/eventsSchema.ts (liveEventHandoffTokens) for the binding +// rationale and the full security model. + +// Token TTL — short by design. A handoff is a single navigation; 10 minutes +// covers a slow cross-domain hop + sign-in detour without leaving a long-lived +// credential alive. +const HANDOFF_TOKEN_TTL_MS = 10 * 60 * 1000; +// Low use cap — the NodeBench bridge consumes once; a couple extra uses absorb +// a refresh / back-button without re-mint. Far below anything an attacker could +// leverage even if a token leaked from history. +const HANDOFF_TOKEN_MAX_USES = 5; +// 32 random bytes = 256 bits. base64url ≈ 43 chars. +const HANDOFF_TOKEN_BYTES = 32; +// Reject obviously-malformed presented tokens before any DB work (BOUND_READ). +const MIN_PRESENTED_TOKEN_LEN = 20; +const MAX_PRESENTED_TOKEN_LEN = 200; +// BOUND: consume returns at most this many notes (read-only projection). +const MAX_HANDOFF_NOTES = 200; +// BOUND: janitor evicts at most this many expired rows per sweep. +const MAX_HANDOFF_TOKEN_EVICT = 500; +// Mint rate limit — per minting session. Generous for humans, caps a token +// flood. Uses the shared DB-backed fixed-window limiter (HONEST_SCORES). +const MINT_RATE_LIMIT_PER_WINDOW = 10; +const MINT_RATE_WINDOW_MS = 60_000; + +// Convex runtime exposes Web Crypto. Same APIs used in convex/events.ts +// (randomString / sha256Hex). CSPRNG, NOT Math.random (DETERMINISTIC + +// unguessable token requirement from agentic_reliability). +const base64UrlEncode = (bytes: Uint8Array): string => { + let bin = ""; + for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]); + // btoa is available in the Convex V8 runtime. + return (globalThis as any) + .btoa(bin) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +}; + +const generateOpaqueToken = (): string => { + const buf = new Uint8Array(HANDOFF_TOKEN_BYTES); + (globalThis as any).crypto.getRandomValues(buf); + return base64UrlEncode(buf); +}; + +const sha256Hex = async (input: string): Promise => { + const data = new TextEncoder().encode(input); + const hashBuffer = await (globalThis as any).crypto.subtle.digest("SHA-256", data); + const bytes = Array.from(new Uint8Array(hashBuffer)); + return bytes.map((b) => b.toString(16).padStart(2, "0")).join(""); +}; + +const cleanSlug = (slug: string): string => + String(slug || "").trim().toLowerCase().slice(0, 120); + +const isRoomCodeShape = (value: string): boolean => + /^[A-Z0-9-]{3,24}$/.test(value); + +// Mirror of events.ts resolveEventBySlugOrRoomCode — kept local so this file +// stays self-contained (it cannot import non-exported helpers from events.ts). +const resolveEvent = async (ctx: any, slug: string) => { + const s = cleanSlug(slug); + if (!s) return null; + const bySlug = await ctx.db + .query("liveEvents") + .withIndex("by_slug", (q: any) => q.eq("slug", s)) + .first(); + if (bySlug) return bySlug; + const roomCode = s.toUpperCase(); + if (!isRoomCodeShape(roomCode)) return null; + return await ctx.db + .query("liveEvents") + .withIndex("by_roomCode", (q: any) => q.eq("roomCode", roomCode)) + .first(); +}; + +/** + * MINT — issue an opaque, event-scoped, read-only handoff token. + * + * Callable from public/proto/home-v5.html on scratchnode.live via the live + * Convex browser client. + * + * Security invariants enforced here: + * #1 The session id is NEVER returned (only { token, expiresAt }). + * #2 Membership is PROVEN: the (eventId, sessionId) pair must exist in + * liveEventMembers (by_event_session). A non-member — or a member of a + * DIFFERENT event — cannot mint. + * #3 Binding is server-side: {eventId, boundOwnerKey=sessionId} is sealed + * in the row; the client only ever holds the opaque token. + * BOUND/HONEST_STATUS: rate-limited; throws typed ConvexError on every + * failure; never returns a token on a denied path. + */ +export const mintEventHandoffToken = mutation({ + args: { + slug: v.string(), + sessionId: v.string(), + }, + handler: async (ctx, { slug, sessionId }) => { + // Shape gate (BOUND_READ) before any DB work. sn_session_id is a UUIDv4 + // (36 chars) or a fallback "sess_..." string; require a sane length. + const sid = String(sessionId || "").trim(); + if (sid.length < 8 || sid.length > 80) { + throw new ConvexError({ + code: "invalid_session", + message: "A valid session is required to continue your notes.", + }); + } + + // Rate-limit per minting session (DB-backed fixed window). Caps a token + // flood even if the caller is a legitimate member. + await enforceRateLimit(ctx, { + key: `handoffmint:${sid}`, + limit: MINT_RATE_LIMIT_PER_WINDOW, + windowMs: MINT_RATE_WINDOW_MS, + }); + + const event = await resolveEvent(ctx, slug); + if (!event) { + // Fail closed — never mint against a phantom event. + throw new ConvexError({ + code: "event_not_found", + message: "That event could not be found.", + }); + } + + // INVARIANT #2 — PROVE membership of THIS event with THIS session. This is + // the authorization gate: only someone who actually joined the room (and + // thus could have written private notes scoped to it) may mint. + const member = await ctx.db + .query("liveEventMembers") + .withIndex("by_event_session", (q: any) => + q.eq("eventId", event._id).eq("sessionId", sid), + ) + .first(); + if (!member) { + throw new ConvexError({ + code: "not_a_member", + message: "Join the event before continuing your notes in NodeBench.", + }); + } + + // INVARIANT #1/#3 — generate the opaque token, store ONLY its hash + the + // server-side binding. boundOwnerKey === sessionId for anonymous guests + // (matches userNotes.ownerKey); it never leaves this row. + const token = generateOpaqueToken(); + const tokenHash = await sha256Hex(token); + const boundOwnerKeyHash = await sha256Hex(sid); + const now = Date.now(); + const expiresAt = now + HANDOFF_TOKEN_TTL_MS; + + await ctx.db.insert("liveEventHandoffTokens", { + tokenHash, + eventId: event._id, + boundOwnerKey: sid, // server-only; never returned, never logged + boundOwnerKeyHash, + scope: "private_notes_read" as const, + createdAt: now, + expiresAt, + usedCount: 0, + maxUses: HANDOFF_TOKEN_MAX_USES, + }); + + // ONLY the opaque token + its expiry cross back to the client. + return { token, expiresAt }; + }, +}); + +export type HandoffNote = { + noteId: string; + title: string; + bodyHtml: string; + tags: string[]; + pinned: boolean; + isAsk: boolean; + createdAt: number; + updatedAt: number; +}; + +export type ConsumeHandoffResult = { + eventName: string; + eventSlug: string; + roomCode: string; + scope: "private_notes_read"; + noteCount: number; + notes: HandoffNote[]; + _truncated: boolean; +}; + +/** + * CONSUME — fail-closed verify + read-only resolution of the bound session's + * private notes for the bound event. + * + * A MUTATION (not a query) because it increments usedCount — single-use/low-use + * enforcement requires a write. + * + * FAIL-CLOSED on every denial: + * - token shape invalid → code=invalid_token + * - no row for this token hash → code=invalid_token (indistinguishable + * from "wrong token" — no enumeration) + * - expired → code=token_expired + * - used up (usedCount >= maxUses) → code=token_used + * - wrong scope → code=invalid_scope + * - bound event vanished → code=event_not_found + * + * RETURNS: ONLY that event's notes for the bound session, READ-ONLY, BOUND at + * MAX_HANDOFF_NOTES. NEVER returns the raw session id / owner key / token. + * The owner is resolved SERVER-SIDE from the row binding — no client-supplied + * owner key is ever trusted (the consume args contain only the token). + */ +export const consumeEventHandoffToken = mutation({ + args: { + token: v.string(), + }, + handler: async (ctx, { token }): Promise => { + const presented = String(token || "").trim(); + if ( + presented.length < MIN_PRESENTED_TOKEN_LEN || + presented.length > MAX_PRESENTED_TOKEN_LEN + ) { + throw new ConvexError({ + code: "invalid_token", + message: "This continuation link is invalid.", + }); + } + + const tokenHash = await sha256Hex(presented); + const row = await ctx.db + .query("liveEventHandoffTokens") + .withIndex("by_token_hash", (q: any) => q.eq("tokenHash", tokenHash)) + .first(); + + // Unknown token → fail closed with the SAME error as a bad shape, so a + // caller cannot distinguish "no such token" from "malformed" (no oracle). + if (!row) { + throw new ConvexError({ + code: "invalid_token", + message: "This continuation link is invalid or has already been used.", + }); + } + + // Scope gate — this capstone only ever issues 'private_notes_read', but + // verify defensively so a future scope can never be honored by this path. + if (row.scope !== "private_notes_read") { + throw new ConvexError({ + code: "invalid_scope", + message: "This link does not grant access to private notes.", + }); + } + + const now = Date.now(); + if (row.expiresAt <= now) { + throw new ConvexError({ + code: "token_expired", + message: "This continuation link has expired. Re-open it from ScratchNode.", + }); + } + + if (row.usedCount >= row.maxUses) { + throw new ConvexError({ + code: "token_used", + message: "This continuation link has already been used up.", + }); + } + + // Resolve the bound event. If it vanished, fail closed. + const event = await ctx.db.get(row.eventId); + if (!event) { + throw new ConvexError({ + code: "event_not_found", + message: "The event for this link no longer exists.", + }); + } + + // Burn one use BEFORE returning data — so a crash mid-read still counts the + // attempt (fail toward fewer uses, never more). + await ctx.db.patch(row._id, { usedCount: row.usedCount + 1 }); + + // INVARIANT #4 — owner resolved SERVER-SIDE from the binding; the consume + // args never carried an owner key. Read ONLY this event's notes for the + // bound session, READ-ONLY, BOUND at MAX_HANDOFF_NOTES. The by_owner_event + // index requires the EXACT ownerKey — which is exactly why boundOwnerKey + // holds the raw session id (a hash can't drive this read). + const rows = await ctx.db + .query("userNotes") + .withIndex("by_owner_event", (q: any) => + q.eq("ownerKey", row.boundOwnerKey).eq("eventId", row.eventId), + ) + .take(MAX_HANDOFF_NOTES + 1); + const truncated = rows.length > MAX_HANDOFF_NOTES; + const limited = rows.slice(0, MAX_HANDOFF_NOTES); + + // Project to a read-only, public-to-the-owner-only shape. NEVER include + // ownerKey / boundOwnerKey / the token. Sort newest-first deterministically. + const notes: HandoffNote[] = limited + .map((n: any) => ({ + noteId: String(n._id), + title: n.title, + bodyHtml: n.bodyHtml, + tags: Array.isArray(n.tags) ? n.tags : [], + pinned: !!n.pinned, + isAsk: !!n.isAsk, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + })) + .sort((a, b) => b.updatedAt - a.updatedAt); + + return { + eventName: event.name, + eventSlug: event.slug, + roomCode: event.roomCode, + scope: "private_notes_read" as const, + noteCount: notes.length, + notes, + _truncated: truncated, + }; + }, +}); + +/** + * JANITOR — evict expired handoff token rows. Called by a cron (mirrors + * scratchnodeRateLimit:_evictStaleRateLimits). BOUND at MAX_HANDOFF_TOKEN_EVICT + * per sweep via the by_expiresAt range; never a full scan. + */ +export const _evictExpiredHandoffTokens = internalMutation({ + args: {}, + handler: async (ctx) => { + const now = Date.now(); + const stale = await ctx.db + .query("liveEventHandoffTokens") + .withIndex("by_expiresAt", (q: any) => q.lt("expiresAt", now)) + .take(MAX_HANDOFF_TOKEN_EVICT); + for (const r of stale) { + await ctx.db.delete(r._id); + } + return { evicted: stale.length }; + }, +}); diff --git a/convex/tools/spreadsheet/README.md b/convex/tools/spreadsheet/README.md index 58f1ca845..523a73dc0 100644 --- a/convex/tools/spreadsheet/README.md +++ b/convex/tools/spreadsheet/README.md @@ -52,6 +52,40 @@ bulkUpdateSpreadsheet: { ## Operation Types +### Database-backed row delta + +For spreadsheet-native state transitions, agents should use `applyRowDelta` on +the Convex-backed spreadsheet surface. + +```typescript +{ + sheetId: "abc123", + row: 0, + expectedUpdatedAt: 1770000000000, // optional optimistic version check + source: "agent.spreadsheet.workflow", + operations: [ + { op: "insert", index: 1, value: 1 }, + { op: "set", index: 3, value: null }, + { op: "delete", index: 4 } + ] +} +``` + +Rules: +- `insert(index, value)` adds a cell and shifts later cells right. +- `delete(index)` removes a cell and shifts later cells left. +- `set(index, value)` updates a cell without shifting. +- `null` is an explicit blank cell value. It is not missing data and it is not + deletion. +- Operations apply in order. +- `expectedUpdatedAt` rejects stale writes with `VersionConflict`. +- Every successful row delta writes a `spreadsheetEvents` audit row with + `before`, `after`, `operations`, `previousVersion`, and `nextVersion`. + +Use `applyRowDelta` when the agent is reconciling a current row with provider +changes or spreadsheet-style deltas. Use `setCell` or `setRange` for direct +cell/range writes. + ### 1. Update Column (Scalar) Set all rows in a column to the same value. @@ -269,4 +303,3 @@ await ctx.runAction( 4. **Batch Operations**: Support multiple files in one call 5. **Real-time Sync**: Add Convex subscriptions for multi-user editing 6. **Column Filtering**: Optimize column-based queries with indexes - diff --git a/convex/tools/spreadsheet/spreadsheetCrudTools.ts b/convex/tools/spreadsheet/spreadsheetCrudTools.ts index 2fe81be6f..2e7047be0 100644 --- a/convex/tools/spreadsheet/spreadsheetCrudTools.ts +++ b/convex/tools/spreadsheet/spreadsheetCrudTools.ts @@ -6,6 +6,7 @@ import { api, internal } from "../../_generated/api"; import type { Id } from "../../_generated/dataModel"; const sheetIdSchema = z.string().min(1); +const cellValueSchema = z.union([z.string(), z.number(), z.null()]); export const createSpreadsheet = createTool({ description: `Create a new spreadsheet (sheet) owned by the current user.`, @@ -40,12 +41,12 @@ export const listSpreadsheets = createTool({ }); export const setCell = createTool({ - description: `Set a single cell value (0-based row/col).`, + description: `Set a single cell value (0-based row/col). Use null for an explicit blank cell, not for deletion.`, args: z.object({ sheetId: sheetIdSchema.describe("Spreadsheet ID"), row: z.number().min(0), col: z.number().min(0), - value: z.string(), + value: cellValueSchema, type: z.string().optional().describe('Optional cell type, e.g. "text", "number", "formula"'), comment: z.string().optional(), }), @@ -68,15 +69,15 @@ export const setCell = createTool({ }); export const setRange = createTool({ - description: `Set a rectangular range. Provide either a constant value or a 2D values array.`, + description: `Set a rectangular range. Provide either a constant value or a 2D values array. Null values persist as explicit blank cells.`, args: z.object({ sheetId: sheetIdSchema, startRow: z.number().min(0), endRow: z.number().min(0), startCol: z.number().min(0), endCol: z.number().min(0), - value: z.string().optional(), - values: z.array(z.array(z.string())).optional(), + value: cellValueSchema.optional(), + values: z.array(z.array(cellValueSchema)).optional(), }), handler: async (ctx, args): Promise => { const res = await ctx.runMutation(api.domains.integrations.spreadsheets.applyOperations, { @@ -114,11 +115,11 @@ export const clearCell = createTool({ }); export const insertRow = createTool({ - description: `Insert an empty row (or with optional values) at the given row index, shifting existing rows down.`, + description: `Insert an empty row (or with optional values) at the given row index, shifting existing rows down. Null values persist as explicit blank cells.`, args: z.object({ sheetId: sheetIdSchema, atRow: z.number().min(0), - values: z.array(z.string()).optional(), + values: z.array(cellValueSchema).optional(), }), handler: async (ctx, args): Promise => { const res = await ctx.runMutation(api.domains.integrations.spreadsheets.insertRow, { @@ -145,6 +146,61 @@ export const deleteRow = createTool({ }, }); +export const applyRowDelta = createTool({ + description: `Apply an ordered row delta to a spreadsheet row. + +Use this for spreadsheet-native transformations where cell positions matter: +- insert(index, value) adds a cell and shifts later cells right +- delete(index) removes a cell and shifts later cells left +- set(index, value) changes one cell without shifting + +Important: null is an explicit blank cell value. It is not the same as a missing operation and it is not deletion.`, + args: z.object({ + sheetId: sheetIdSchema.describe("Spreadsheet ID"), + row: z.number().min(0).describe("0-based row index"), + operations: z.array( + z.discriminatedUnion("op", [ + z.object({ + op: z.literal("insert"), + index: z.number().min(0), + value: cellValueSchema, + }), + z.object({ + op: z.literal("delete"), + index: z.number().min(0), + }), + z.object({ + op: z.literal("set"), + index: z.number().min(0), + value: cellValueSchema, + }), + ]), + ).min(1), + expectedUpdatedAt: z.number().optional().describe("Optional optimistic version from the sheet updatedAt field"), + source: z.string().optional().describe("Source system or workflow label for audit logging"), + threadId: z.string().optional(), + runId: z.string().optional(), + }), + handler: async (ctx, args): Promise => { + const res = await ctx.runMutation(api.domains.integrations.spreadsheets.applyRowDelta, { + sheetId: args.sheetId, + row: args.row, + operations: args.operations, + expectedUpdatedAt: args.expectedUpdatedAt, + source: args.source, + threadId: args.threadId, + runId: args.runId, + }); + return [ + `Applied ${res.applied} row-delta operations to row ${args.row} on ${args.sheetId}.`, + `Before: ${JSON.stringify(res.before)}`, + `After: ${JSON.stringify(res.after)}`, + `Version: ${res.previousVersion} -> ${res.nextVersion}`, + `Audit event: ${res.eventId}`, + ].join("\n"); + }, +}); + export const getSpreadsheet = createTool({ description: `Get full spreadsheet metadata + all populated cells.`, args: z.object({ @@ -185,8 +241,8 @@ export const spreadsheetCrudTools = { setCell, clearCell, setRange, + applyRowDelta, insertRow, deleteRow, getSpreadsheet, }; - diff --git a/docs/architecture/SCRATCHNODE_NODEBENCH_BOUNDARY.md b/docs/architecture/SCRATCHNODE_NODEBENCH_BOUNDARY.md index ce0d60d70..bb6c37a7c 100644 --- a/docs/architecture/SCRATCHNODE_NODEBENCH_BOUNDARY.md +++ b/docs/architecture/SCRATCHNODE_NODEBENCH_BOUNDARY.md @@ -61,14 +61,18 @@ https://scratchnode.live/sign-in NodeBench private continuation URLs: ```text -https://nodebenchai.com/events/:eventSlug/private?source=scratchnode&room=:roomCode&continuation=private-notes¬eCount=:count&publicArtifact=event-wiki&return=:scratchnodeEventUrl -https://nodebenchai.com/sign-in?return=:nodebenchPrivateEventUrl&intent=save-private-notes +Success path: +https://nodebenchai.com/events/:eventSlug/private?token=:opaqueHandoffToken&source=scratchnode&room=:roomCode + +Honest fallback: +https://nodebenchai.com/scratchnode-events?source=scratchnode&event=:eventSlug&room=:roomCode&continuation=private-notes¬eCount=:count&publicArtifact=event-wiki&return=:scratchnodeEventUrl ``` -The handoff URL carries continuation intent and counts, not the anonymous -`ownerKey` itself. The `ownerKey` remains a server-validated note-operation key; -future cross-domain import should use a one-time exchange token rather than -placing session secrets in URLs. +The success-path handoff URL carries only an opaque short-lived token, never the +anonymous `ownerKey` itself. The `ownerKey` remains a server-validated note key +bound to a server-only `liveEventHandoffTokens` row. If token minting is not +available or is denied, ScratchNode falls back to `/scratchnode-events` with +event context, not a tokenless `/events/:eventSlug/private` URL. ScratchNode sign-in owns event participation state. NodeBench sign-in is only for the explicit "Open in NodeBench" private-workspace continuation. diff --git a/docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md b/docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md index 8e45ba700..cc6f9b5ec 100644 --- a/docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md +++ b/docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md @@ -52,6 +52,14 @@ codex --sandbox workspace-write --ask-for-approval on-request In Codex Desktop, emulate the same contract by treating the goal text as the active acceptance checklist and continuing the local fix, verify, and evidence loop until every gate is satisfied. +## Self-Directed Loop Pattern + +Use a batched issue queue instead of chasing one warning at a time. Classify every pass into blockers, actionable attention, known-safe cautions, and exactly one next development candidate. + +Run specialist passes in a stable order so the loop stays comparable across turns: housekeeping, product workflow, backend or handoff contracts, privacy and reliability, performance and accessibility, and automation evidence. + +Track cost/effort accounting before taking a slice. Prefer a small detector, targeted test, or local product fix that can be verified and committed in the same turn; defer work that would need deploys, live writes, or broad refactors. + ## Goal Template ```text diff --git a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md index 721060b9b..ea7b06525 100644 --- a/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md +++ b/docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md @@ -138,9 +138,15 @@ The script: - generates public repo metadata, - creates a minimal ScratchNode-only `vercel.json`, - writes the public Convex API/string-call contract, +- writes event-log projection evidence that separates public event-log JSON from owner-only private note projection, - scans the output for forbidden files and sensitive strings, - exits non-zero on export safety failures. +Exported `contracts/scratchnode-live-api.json` must keep two projections explicit: + +- `publicEventLogJson`: public event metadata, public chat, public `/ask` answers, host-promoted wiki sections, public sources, and typed manual location spots. It excludes private notes, owner keys, session ids, handoff tokens, and NodeBench workspace artifacts. +- `ownerPrivateNoteProjection`: owner-only private notes, anchors, manual location spot anchors, follow-ups, voice transcripts, and NodeBench handoff context. It excludes public wiki JSON, public `/ask` cache, public answer traces, and other attendees' notes. + ## Backend Compatibility Rule Backend changes remain in `nodebench-ai` and must be additive-first: @@ -179,9 +185,9 @@ users:mergeGuestSession Use this framing: -> ScratchNode Live is a public event-room prototype where people join with a code, chat normally, use `/ask` for sourced answers, and leave behind a public event wiki. Private notes stay private and can sync into NodeBench later. +> ScratchNode Live is an open-source event log assistant and memory layer for live events. People join with a code, chat normally, use `/ask` for sourced answers, and leave behind a public event wiki. Private notes stay private and can sync into NodeBench later. -Do not claim the public split is the full NodeBench backend. Do not claim every URL contract is fully implemented just because the Vercel catch-all returns `200`. +Do not claim the public split is the full NodeBench backend. Do not claim every URL contract is fully implemented just because the Vercel catch-all returns `200`. Do not describe the public export as a final production system. ## Release Verification @@ -203,4 +209,3 @@ After deployment: 5. Open scratchnode.live/demo_ver1. Demo automation may run there only. 6. Open scratchnode.live/api/scratchnode-config. It returns only public config. ``` - diff --git a/docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md b/docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md index c2e40dab6..2ada0cd9a 100644 --- a/docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md +++ b/docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md @@ -33,6 +33,16 @@ ## Pre-flight checklist (run 60 min before launch) +### 0. Continuous goal-loop gate + +Before manual launch checks, run the local autonomous launch goal loop: + +```bash +npm run scratchnode:launch:goal +``` + +Expect `ScratchNode launch goal loop: PASS`. If it fails, inspect `.tmp/scratchnode-launch-goal-loop.json` before continuing; do not deploy or publish from this loop. + ### 1. PR #423 has merged + deployed ```bash diff --git a/goals/scratchnode/004-event-log-followups.md b/goals/scratchnode/004-event-log-followups.md new file mode 100644 index 000000000..6de0a5c5b --- /dev/null +++ b/goals/scratchnode/004-event-log-followups.md @@ -0,0 +1,69 @@ +--- +id: scratchnode-event-log-followups +title: ScratchNode event log follow-ups +status: queued +surface: scratchnode.live +priority: P1 +mode: safe-local-development +--- + +# Goal: ScratchNode event log follow-ups + +Make ScratchNode's self-directed loop deepen the product as an open-source event log assistant: +timeline, public chat, private notes, manual location spots, people/company tags, photos, `/ask`, +host FAQ promotion, published wiki, export, and NodeBench private follow-up. + +## Product Framing + +ScratchNode is the memory layer for live events, not an invite, ticketing, or RSVP tool. + +```text +Luma / Eventbrite / Partiful = invite, RSVP, ticket, event page +ScratchNode = live event memory layer +NodeBench = private research and workspace after the event +``` + +## Event Log Primitive + +Treat every user-visible workflow as an event-log moment and projection. + +```text +Event Log +-> Timeline +-> Public chat +-> Private notes +-> Manual location spots +-> People and companies +-> Photos and voice notes +-> Questions and agent answers +-> Sources +-> Wiki +-> Export and NodeBench handoff +``` + +## Safe Follow-Up Slices + +Choose one narrow, locally verifiable slice per loop: + +- Add a route test proving a public event-log moment appears in the timeline or wiki without leaking private notes. +- Add a route test proving a private note can be anchored to a chat, person/company, or manual location spot without entering public chat, public `/ask`, cache, or wiki. +- Add a detector or fixture for manual location spots such as Booth 12, Lobby, Panel Room A, Investor Lounge, or Afterparty. +- Add a NodeBench handoff check that private follow-ups, people, companies, topics, and event wiki links remain separated by visibility. +- Add export evidence for public event log JSON and owner-only private note projection. +- Improve wording or docs so the public repo says "open-source event log assistant" and "memory layer for live events" without claiming production readiness. + +## Definition of Done + +- [ ] One safe slice is implemented, tested, and committed. +- [ ] Public/private visibility remains explicit in UI, data fixtures, traces, and tests. +- [ ] Normal chat, notes, tags, photos, check-ins, replies, and host announcements stay no-LLM by default. +- [ ] `/ask`, wiki compaction, entity extraction, and follow-up generation stay explicit agent actions. +- [ ] `npm run scratchnode:launch:goal` passes after the slice. + +## Constraints + +- Do not build real GPS/geofencing before manual location spots. +- Do not build a full graph view before cards/lists work. +- Do not replace Luma, Eventbrite, Partiful, Slack, or Discord. +- Do not add NodeBench as a ScratchNode top-level tab. +- Do not weaken the private-note, host-role, or public-cache boundary. diff --git a/package.json b/package.json index 1bcfdd034..0ef813078 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,16 @@ "lint:design:fix": "node scripts/ui/designLinter.mjs --fix-suggestions", "lint:agent-ui": "node scripts/ui/agentNativeUiLinter.mjs", "lint:agent-ui:json": "node scripts/ui/agentNativeUiLinter.mjs --json", + "repo:augment:check": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/checkAugmentUploadScope.ps1", + "repo:history:map": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/mapReduceLocalHistory.ps1", + "repo:housekeeping": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/runWorkspaceHousekeeping.ps1", + "repo:housekeeping:verify": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/verifyWorkspaceHousekeeping.ps1", + "repo:housekeeping:self-test": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/repo/testWorkspaceHousekeeping.ps1", + "repo:housekeeping:check": "npm run repo:augment:check && npm run repo:housekeeping:verify && git diff --cached --check", + "scratchnode:launch:scan": "node scripts/scratchnode/scanLaunch.mjs", + "scratchnode:launch:check": "node scripts/scratchnode/scanLaunch.mjs --live", + "scratchnode:launch:interactive": "node scripts/scratchnode/scanLaunch.mjs --live --interactive", + "scratchnode:launch:goal": "node scripts/scratchnode/runLaunchGoalLoop.mjs", "dogfood:qa:gemini": "node scripts/ui/runDogfoodGeminiQa.mjs", "dogfood:loop": "node scripts/ui/runDogfoodGeminiQa.mjs --loop --max-iterations 5 --target-score 100 --target-aspiration 92 --design-edits", "dogfood:loop:auto": "node scripts/ui/runDogfoodGeminiQa.mjs --loop --auto-apply --max-iterations 5 --target-score 100 --target-aspiration 92 --design-edits", diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index a562571dc..3db5c86d4 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -54,6 +54,18 @@ --purple: #a78bfa; --ui: "Manrope", system-ui, -apple-system, sans-serif; --mono: "JetBrains Mono", "SF Mono", Menlo, monospace; + /* ─── Type scale (one ladder, so the eye can rank) ─── + Discipline: ONE --fs-display per screen. --mono is reserved for machine + identifiers ONLY (room code + the /ask token), never human-readable prose. + --accent (solid terracotta) marks the SINGLE primary action on a screen; + everything else uses --accent-ghost or a neutral border. */ + --fs-display: 22px; /* the one large element: empty-state title / hero */ + --fs-title: 15px; /* event title, menu row, answer heads */ + --fs-base: 14px; /* chat + body */ + --fs-sub: 12px; /* metadata, helper, status */ + --fs-label: 11px; /* tracked caps section headers */ + --fs-mono: 11px; /* room code + /ask token only */ + --accent-ghost: rgba(217,119,87,.10); --r: 12px; --r-sm: 8px; --motion-fast: 120ms; @@ -114,22 +126,25 @@ .h-logo { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 14px; } .h-logo-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px rgba(217,119,87,.7); animation: logoBreathe 3.4s ease-in-out infinite; } @keyframes logoBreathe { 0%,100% { transform: scale(1); box-shadow: 0 0 10px rgba(217,119,87,.62); } 50% { transform: scale(1.28); box-shadow: 0 0 18px rgba(217,119,87,.92); } } -.h-logo span { color: var(--accent); } +.h-logo-word { display: inline-flex; } /* keeps "ScratchNode" as one word — the .h-logo gap must not split it */ +.h-logo-word > span { color: var(--accent); } .h-live { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 100px; background: rgba(94,168,103,.1); border: 1px solid rgba(94,168,103,.25); font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--green); letter-spacing: .1em; } .h-live::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px rgba(94,168,103,.7); animation: livePulse 2s infinite; } @keyframes livePulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .42; transform: scale(.75); } } @media (prefers-reduced-motion: reduce) { body::before, .h-logo-dot, .h-live::before { animation: none; } } .h-spacer { flex: 1; } +/* Room code = quiet machine-id chip (mono is allowed here — it IS an identifier). + NOT accent: accent is reserved for the one primary action on screen. */ .h-code { - min-height: 36px; padding: 6px 14px; border-radius: var(--r-sm); - background: transparent; border: 1px solid var(--line); - color: var(--accent); font-family: var(--mono); font-size: 12px; font-weight: 700; - letter-spacing: .18em; cursor: pointer; transition: border-color .12s; + min-height: 30px; padding: 4px 11px; border-radius: 100px; + background: rgba(255,255,255,.04); border: 1px solid var(--line); + color: var(--ink-muted); font-family: var(--mono); font-size: var(--fs-mono); font-weight: 600; + letter-spacing: .14em; cursor: pointer; transition: border-color .12s, color .12s; } -.h-code:hover { border-color: var(--accent); } -.h-menu { width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--r-sm); border: 1px solid var(--line); background: transparent; color: var(--ink-muted); } -.h-menu:hover { color: var(--ink); border-color: var(--ink-faint); } -@media (max-width: 540px) { .h-menu { width: 44px; height: 44px; } .h-code { min-height: 44px; } } +.h-code:hover { border-color: var(--ink-faint); color: var(--ink); } +.h-menu { width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--r-sm); border: 0; background: transparent; color: var(--ink-faint); } +.h-menu:hover { color: var(--ink); background: rgba(255,255,255,.05); } +@media (max-width: 540px) { .h-menu { width: 44px; height: 44px; } .h-code { min-height: 36px; } } /* ─── Event identity strip (persistent after scroll) ─── */ .event-strip { @@ -139,26 +154,35 @@ background: linear-gradient(180deg, rgba(21,20,19,.86), rgba(21,20,19,.5)); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border-bottom: 1px solid var(--line); - font-family: var(--mono); font-size: 10px; color: var(--ink-muted); - letter-spacing: .04em; + font-family: var(--ui); font-size: var(--fs-sub); color: var(--ink-muted); + letter-spacing: 0; overflow-x: auto; white-space: nowrap; scrollbar-width: none; } .event-strip::-webkit-scrollbar { display: none; } -.event-strip b { color: var(--ink); font-weight: 700; font-family: var(--ui); font-size: 11px; letter-spacing: 0; } +.event-strip b { color: var(--ink); font-weight: 700; font-family: var(--ui); font-size: var(--fs-sub); letter-spacing: 0; } .event-strip .dot { color: var(--ink-faint); } +/* Host/debug controls (event mode, capture level, FAQ count) are NOT for public + attendees — they leaked into the public strip. Gate them to the host role. + Elements stay in the DOM (JS reads ev-faq-count / ev-cap-label / ev-mode-label). */ +.event-strip .ev-mode, +.event-strip .ev-cap, +.event-strip .ev-host-only { display: none; } +body[data-role="host"] .event-strip .ev-mode, +body[data-role="host"] .event-strip .ev-cap { display: inline-flex; } +body[data-role="host"] .event-strip .ev-host-only { display: inline; } .event-strip .ev-link { margin-left: auto; - color: var(--accent); border: 1px solid rgba(217,119,87,.3); - padding: 2px 8px; border-radius: 100px; + color: var(--ink-muted); border: 1px solid var(--line); + padding: 3px 10px; border-radius: 100px; text-decoration: none; cursor: pointer; flex-shrink: 0; } -.event-strip .ev-link:hover { background: rgba(217,119,87,.08); } +.event-strip .ev-link:hover { color: var(--ink); border-color: var(--ink-faint); } @media (max-width: 540px) { - .event-strip { padding: 5px 14px; font-size: 9px; gap: 8px; } - .event-strip b { font-size: 10px; } - .event-strip .ev-link { font-size: 9px; } + .event-strip { padding: 6px 14px; font-size: var(--fs-sub); gap: 8px; } + .event-strip b { font-size: var(--fs-sub); } + .event-strip .ev-link { font-size: var(--fs-label); } } /* ─── Main (single column) ─── */ @@ -174,9 +198,9 @@ @media (prefers-reduced-motion: reduce) { main.m { animation: none; } } /* ─── Hero (small, scannable) ─── */ -.hero { margin-bottom: 20px; } -.hero h1 { font-size: 26px; font-weight: 800; letter-spacing: -.02em; margin: 0 0 4px; } -.hero-meta { font-size: 13px; color: var(--ink-muted); } +.hero { margin-bottom: 18px; } +.hero :is(h1, h2) { font-size: var(--fs-display); font-weight: 800; letter-spacing: -.02em; margin: 0 0 4px; } +.hero-meta { font-size: var(--fs-sub); color: var(--ink-muted); } .hero-meta b { color: var(--ink); font-weight: 600; } /* ─── Composer (gravitational center) ─── */ @@ -242,6 +266,7 @@ border: 1px solid var(--line); } .c-helpline .pill.ask { color: var(--accent); border-color: rgba(217,119,87,.3); background: rgba(217,119,87,.06); } +.c-helpline .privacy-state { margin-left: auto; font-size: var(--fs-label); font-weight: 600; color: var(--ink-muted); } body[data-mode="private"] .c-helpline .privacy-state { color: var(--purple); } /* Role-gated host-only actions on agent cards */ @@ -272,7 +297,7 @@ /* ─── Feed ─── */ .feed { display: flex; flex-direction: column; gap: 4px; } -.feed-divider { display: flex; align-items: center; gap: 10px; margin: 8px 0 4px; font-family: var(--mono); font-size: 10px; color: var(--ink-faint); letter-spacing: .12em; text-transform: uppercase; } +.feed-divider { display: flex; align-items: center; gap: 10px; margin: 8px 0 4px; font-family: var(--ui); font-size: var(--fs-label); font-weight: 600; color: var(--ink-faint); letter-spacing: .12em; text-transform: uppercase; } .feed-divider::after { content: ''; flex: 1; height: 1px; background: var(--line); } /* Chat row (minimal, dense) */ @@ -304,6 +329,11 @@ .row-name[data-role="you"] { color: var(--accent); } .row-name[data-role="mod"] { color: var(--green); } .row-text { font-size: 14px; color: var(--ink-muted); line-height: 1.5; word-wrap: break-word; } +.sn-location-spot { + display: inline-flex; align-items: center; gap: 4px; width: fit-content; margin-top: 4px; + padding: 2px 8px; border-radius: 999px; border: 1px solid rgba(217,119,87,.28); + background: rgba(217,119,87,.10); color: var(--accent); font-family: var(--mono); font-size: 10px; +} /* Grouped consecutive messages from the same author (Discord-style compaction) */ .row--grouped { margin-top: -3px; padding-top: 1px; } .row--grouped .row-avatar { visibility: hidden; } @@ -385,6 +415,11 @@ .mention:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-color: rgba(217,119,87,.5); } .mention:active { transform: scale(.96); } .mention.is-you { background: rgba(217,119,87,.15); color: var(--accent); } +.hashtag { + display: inline-block; padding: 1px 7px; border-radius: 100px; + background: rgba(217,119,87,.11); color: var(--accent); border: 1px solid rgba(217,119,87,.22); + font-size: 13px; font-weight: 600; text-decoration: none; vertical-align: baseline; +} /* — @mention picker dropdown (above composer when typing @) — */ .mention-picker { @@ -621,7 +656,7 @@ .menu-sheet button { min-height: 44px; } .menu-sheet[data-open="true"] { transform: translateY(0); } .menu-sheet-handle { width: 32px; height: 3px; border-radius: 2px; background: var(--line); margin: 0 auto 14px; } -.menu-sheet h4 { margin: 0 0 8px; font-size: 11px; font-weight: 700; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .12em; font-family: var(--mono); } +.menu-sheet h4 { margin: 0 0 8px; font-size: var(--fs-label); font-weight: 700; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .12em; font-family: var(--ui); } .menu-sheet button { display: block; width: 100%; text-align: left; padding: 10px 12px; margin: 2px 0; @@ -644,18 +679,23 @@ .menu-sheet h4[data-show-for]:not([data-show-for="all"]) { display: none; } body[data-role="host"] .menu-sheet h4[data-show-for="host"] { display: block; } body[data-named="true"] .menu-sheet h4[data-show-for="named"] { display: block; } +/* Keyboard shortcuts is a desktop affordance — hide it from the mobile sheet. */ +@media (max-width: 540px) { .menu-sheet .menu-desktop-only { display: none; } } .menu-scrim { position: fixed; inset: 0; z-index: 105; background: rgba(0,0,0,.5); display: none; } .menu-scrim[data-open="true"] { display: block; } /* ─── First-visit welcome banner ─── */ .welcome { - display: none; align-items: center; gap: 10px; padding: 10px 14px; + display: none; align-items: center; gap: 10px; padding: 9px 12px; margin: 0 0 12px; - background: linear-gradient(135deg, rgba(217,119,87,.1), rgba(217,119,87,.02)); - border: 1px solid rgba(217,119,87,.25); border-radius: var(--r-sm); - font-size: 12px; color: var(--ink-muted); + background: rgba(255,255,255,.03); + border: 1px solid var(--line); border-radius: var(--r-sm); + font-size: var(--fs-sub); color: var(--ink-muted); } body[data-new-user="true"] .welcome { display: flex; } +/* Mobile: the empty-state copy + composer already carry the how-to. The banner + is redundant chrome on a small first viewport — hide it. */ +@media (max-width: 540px) { body[data-new-user="true"] .welcome { display: none; } } .welcome-emoji { font-size: 16px; } .welcome-text { flex: 1; line-height: 1.4; } .welcome-text strong { color: var(--ink); } @@ -683,7 +723,7 @@ .id-avatar { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), #b85f44); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; } .id-text { flex: 1; } .id-text b { color: var(--ink); font-weight: 600; } -.id-set { background: transparent; border: 0; color: var(--accent); font-size: 11px; cursor: pointer; padding: 4px 6px; border-radius: 4px; font-family: var(--mono); } +.id-set { background: transparent; border: 0; color: var(--accent); font-size: var(--fs-sub); cursor: pointer; padding: 4px 6px; border-radius: 4px; font-family: var(--ui); } .id-set:hover { background: rgba(217,119,87,.08); } /* ─── Empty state (when feed has zero rows) ─── */ @@ -695,8 +735,9 @@ body[data-feed-empty="true"] #feed > .row, body[data-feed-empty="true"] #feed > .ans { display: none; } .empty-icon { font-size: 28px; opacity: .6; margin-bottom: 4px; } -.empty-title { font-size: 14px; color: var(--ink); font-weight: 600; } -.empty-body { font-size: 12px; color: var(--ink-muted); max-width: 280px; line-height: 1.5; } /* AA contrast — instructional empty-state copy must be readable */ +.empty-title { font-size: 18px; color: var(--ink); font-weight: 700; letter-spacing: -.01em; } /* the one display element when the feed is empty */ +.empty-body { font-size: var(--fs-sub); color: var(--ink-muted); max-width: 280px; line-height: 1.5; } /* AA contrast — instructional empty-state copy must be readable */ +.empty-body b { color: var(--accent); font-family: var(--mono); font-weight: 600; } /* /ask token */ /* ─── Attention pulses for first-visit ─── */ body[data-new-user="true"] #lock, @@ -736,13 +777,12 @@ /* ─── "What is this" inline link ─── */ .about-link { - display: inline-flex; align-items: center; gap: 4px; - margin-left: 6px; padding: 2px 7px; border-radius: 100px; - background: rgba(255,255,255,.04); border: 1px solid var(--line); - color: var(--ink-faint); font-size: 10px; font-family: var(--mono); letter-spacing: .04em; - text-transform: uppercase; cursor: pointer; transition: all .12s; + display: inline; margin-left: 7px; padding: 0; + background: none; border: 0; + color: var(--ink-faint); font-size: var(--fs-sub); font-family: var(--ui); letter-spacing: 0; + text-transform: none; cursor: pointer; transition: color .12s; } -.about-link:hover { color: var(--ink); border-color: var(--ink-faint); } +.about-link:hover { color: var(--ink); text-decoration: underline; } /* ─── UNIVERSAL FEATURE SHEET (slides up — name, about, share, notes, wiki, people, signin, host, shortcuts) ─── */ .sheet-scrim { position: fixed; inset: 0; z-index: 115; background: rgba(0,0,0,.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); display: none; } @@ -838,7 +878,7 @@ .wiki-search input { flex: 1; background: transparent; border: 0; outline: none; color: var(--ink); font-size: 12px; font-family: var(--ui); } .wiki-search kbd { font-family: var(--mono); font-size: 10px; padding: 1px 5px; background: rgba(255,255,255,.05); border: 1px solid var(--line); border-radius: 3px; color: var(--ink-faint); } .wiki-article { min-width: 0; max-width: 640px; font-size: 14px; color: var(--ink); line-height: 1.65; } -.wiki-article h1 { font-size: 24px; font-weight: 800; letter-spacing: -.02em; margin: 0 0 4px; color: var(--ink); } +.wiki-article h1, .wiki-article .wiki-title { font-size: 24px; font-weight: 800; letter-spacing: -.02em; margin: 0 0 4px; color: var(--ink); } .wiki-article > .lead { font-size: 14px; color: var(--ink-muted); margin: 0 0 24px; line-height: 1.55; } .wiki-article h2 { font-size: 18px; font-weight: 700; letter-spacing: -.01em; margin: 32px 0 10px; color: var(--ink); padding-top: 16px; border-top: 1px solid var(--line); position: relative; scroll-margin-top: 16px; } .wiki-article h2:first-of-type { padding-top: 0; border-top: 0; margin-top: 0; } @@ -973,7 +1013,7 @@ .notes-backlink-row:hover { background: rgba(167,139,250,.08); } /* Keyboard shortcuts overlay */ -.kbd-overlay { display: none; position: fixed; inset: 0; z-index: 130; background: rgba(0,0,0,.6); backdrop-filter: blur(8px); align-items: center; justify-content: center; padding: 20px; } +.kbd-overlay { display: none; position: fixed; inset: 0; z-index: 130; background: rgba(0,0,0,.6); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); align-items: center; justify-content: center; padding: 20px; } .kbd-overlay[data-open="true"] { display: flex; animation: fadeIn .15s ease-out; } .kbd-dialog { background: var(--paper); border: 1px solid var(--line); border-radius: 12px; width: 420px; max-width: 92vw; padding: 22px 24px; box-shadow: 0 20px 60px rgba(0,0,0,.5); } .kbd-dialog h2 { margin: 0 0 14px; font-size: 14px; font-weight: 700; color: var(--ink); } @@ -988,9 +1028,7 @@ .about-card h4 { margin: 0 0 4px; font-size: 13px; color: var(--accent); font-weight: 700; } .about-card p { margin: 0; font-size: 12px; color: var(--ink-muted); line-height: 1.55; } -/* Empty state CTA */ -.empty-cta { margin-top: 14px; padding: 10px 18px; min-height: 44px; background: var(--accent); color: #fff; border: 0; border-radius: var(--r-sm); font-family: var(--ui); font-size: 13px; font-weight: 600; cursor: pointer; } -.empty-cta:hover { filter: brightness(1.08); } +/* Empty-state CTA removed — the composer IS the call to action (no duplicate button). */ /* Footer (tiny) */ .f { padding: 24px 20px calc(32px + var(--safe-bot)); text-align: center; font-size: 11px; color: var(--ink-faint); } @@ -1001,17 +1039,25 @@ @media (max-width: 540px) { .h { padding: calc(10px + var(--safe-top)) calc(14px + var(--safe-right)) 10px calc(14px + var(--safe-left)); } .m { padding: 16px calc(14px + var(--safe-right)) calc(112px + var(--safe-bot)) calc(14px + var(--safe-left)); } - .hero h1 { font-size: 22px; } + .hero :is(h1, h2) { font-size: 22px; } /* Mobile: pin the composer to the bottom (Slack/Discord/iMessage convention — thumb reach, newest message sits right above where you type). Desktop keeps the sticky top command bar. */ .c { - position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 45; + position: fixed; top: auto; left: 0; right: 0; z-index: 45; + /* Pin above the on-screen keyboard — --keyboard-offset is set from + visualViewport when the input is focused (see keyboard-aware script). */ + bottom: var(--keyboard-offset, 0px); margin: 0; padding: 8px calc(14px + var(--safe-right)) calc(8px + var(--safe-bot)) calc(14px + var(--safe-left)); border-top: 1px solid var(--line); background: var(--bg); box-shadow: 0 -8px 24px -12px rgba(0,0,0,.55); + transition: bottom .18s var(--ease-out); } + @media (prefers-reduced-motion: reduce) { .c { transition: none; } } + /* While typing: collapse non-essential chrome so input + feed stay visible above the keyboard. */ + body[data-input-focused="true"] .f, + body[data-input-focused="true"] .welcome { display: none; } .row { grid-template-columns: 32px 1fr; column-gap: 8px; padding: 5px 4px 6px; } .row-avatar { width: 32px; height: 32px; font-size: 12px; } .row-time { font-size: 9px; } @@ -1022,7 +1068,7 @@ .c-mode { padding: 0; width: 26px; min-width: 26px; height: 26px; justify-content: center; gap: 0; } .c-mode #c-mode-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .c-mode .dot { width: 7px; height: 7px; } - .feed-divider { font-size: 9px; } + .feed-divider { font-size: var(--fs-label); } } /* Landscape (short height) — hide hero, compact composer */ @@ -1185,7 +1231,7 @@ } .nodebench-overlay .nb-header { position: sticky; top: 0; z-index: 1; - padding: 14px 20px; background: rgba(14,16,24,.94); backdrop-filter: blur(20px); + padding: 14px 20px; background: rgba(14,16,24,.94); -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); border-bottom: 1px solid rgba(255,255,255,.06); display: flex; align-items: center; gap: 12px; } @@ -1234,7 +1280,7 @@ z-index: 90; padding: 6px 12px; border-radius: 100px; - background: rgba(21,20,19,.92); backdrop-filter: blur(10px); + background: rgba(21,20,19,.92); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border: 1px solid var(--accent); font-family: var(--mono); font-size: 10px; color: var(--accent); letter-spacing: .08em; @@ -1731,6 +1777,7 @@ display: flex; align-items: center; gap: 12px; padding: 12px 20px; background: color-mix(in srgb, var(--paper) 86%, transparent); + -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); } @@ -1888,6 +1935,37 @@ margin: 0; max-width: 460px; } +.landing-flow { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + width: min(100%, 500px); + margin-top: 2px; +} +.landing-flow__step { + min-height: 48px; + padding: 8px 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255,255,255,.52); + color: var(--ink); + font-family: var(--ui); + font-size: 11px; + line-height: 1.2; + text-align: left; +} +.landing-flow__step b { + display: block; + color: var(--accent); + font-family: var(--mono); + font-size: 10px; + letter-spacing: .06em; + text-transform: uppercase; +} +@media (max-width: 640px) { + .landing-flow { grid-template-columns: 1fr; } + .landing-flow__step { min-height: 0; } +} .landing-join { display: flex; gap: 8px; @@ -2808,6 +2886,7 @@ } catch (e) { /* fall through to polling */ } } var poll = function () { + if (document.hidden) return; client.query('events:getLandingStats', {}) .then(function (stats) { try { render(stats); } catch (e) {} }) .catch(function () {}); @@ -2974,6 +3053,7 @@ } catch (e) { /* fall through to poll */ } } timer = setInterval(function () { + if (document.hidden) return; client.query('events:getMyJoinRequest', { slug: slug, sessionId: sessionId }) .then(function (st) { try { apply(st); } catch (_) {} }) .catch(function () {}); @@ -3092,13 +3172,15 @@ try { document.title = (wiki.eventName || 'Event') + ' — wiki · ScratchNode'; } catch (e) {} var when = ''; try { if (wiki.publishedAt) when = new Date(wiki.publishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch (e) {} + var nodeBenchWikiUrl = buildNodeBenchPublicWikiUrl(wiki); mainEl.innerHTML = '
Event wiki' + (typeof wiki.version === 'number' ? ' · v' + wiki.version : '') + '
' + - '

' + + '

' + '
' + '
' + '
' + '
The room remembers everything. This recap was built live with ScratchNode.
' + + 'Continue in NodeBench →' + 'Create your own room →' + '
'; mainEl.querySelector('.sn-wiki__title').textContent = wiki.title || (wiki.eventName ? wiki.eventName + ' — wiki' : 'Event wiki'); @@ -3108,10 +3190,6 @@ (when ? ' · published ' + when : ''); // bodyHtml is server-built + public-safe (private notes excluded at publish time). mainEl.querySelector('#sn-wiki-article').innerHTML = wiki.bodyHtml; - // NOTE: a "Continue in NodeBench" bridge CTA is intentionally NOT here yet — the - // nodebenchai.com/events//wiki receiving route doesn't exist (the /events - // router rejects trailing segments), so a CTA would 404. It ships WITH its real - // receiving route so the link can never dead-end. See PR-D. } function _snWikiShare() { @@ -3159,6 +3237,13 @@

The room remembers everything.

Drop a code, chat live, hit /ask for sourced answers — and walk away with a wiki of everything that happened. No account, no app.

+ @@ -3208,7 +3293,7 @@

Your event is live.

ScratchNode
Your event
-
Scan to join the room
+
Scan to join the room
scratchnode.live/e/code
the room remembers everything
@@ -3240,7 +3325,7 @@

Your wiki is live.

ScratchNode
Your event
-
Scan to read the wiki
+
Scan to read the wiki
scratchnode.live/wiki/slug
the room remembers everything
@@ -3262,7 +3347,7 @@

Your wiki is live.

- + LIVE
@@ -3278,8 +3363,8 @@

Your wiki is live.

MCP Auth breakout · 0 joined - · - 0 FAQ + · + 0 FAQ @@ -3298,7 +3383,7 @@

Your wiki is live.

- New to ScratchNode? This is the sidecar room: chat publicly, /ask for sourced answers, or lock the composer for private notes. Take the 20-second tour → + Message normally. Start with /ask for sourced answers · 🔒 saves privately. Tour →
@@ -3313,8 +3398,8 @@

Your wiki is live.

-

AI Infra Summit

-

Disposable event brain · 0 joined · public wiki later

+

AI Infra Summit

+

Live event log · public wiki when it ends

@@ -3345,7 +3430,7 @@

AI Infra Summit +
Send a message, ask with /ask for a sourced answer, or 🔒 save a private note.
@@ -3740,7 +3822,7 @@

This event

Your notes

- +

For hosts

@@ -3749,7 +3831,7 @@

Account

- +

More

@@ -3874,6 +3956,22 @@

Keyboard shortcuts

return EVENT_URL + '/wiki'; } +function buildNodeBenchPublicWikiUrl(wiki) { + var slug = ''; + var roomCode = ''; + try { + slug = (wiki && (wiki.eventSlug || wiki.slug)) || (window._sn_wiki && window._sn_wiki.slug) || EVENT_SLUG; + roomCode = (wiki && wiki.roomCode) || EVENT_ROOM_CODE; + } catch (e) { + slug = EVENT_SLUG; + roomCode = EVENT_ROOM_CODE; + } + return WORKSPACE_BASE_URL + + '/events/' + encodeURIComponent(slug) + '/wiki' + + '?source=scratchnode' + + '&room=' + encodeURIComponent(roomCode || ''); +} + function copyTextOrToast(text, successTitle, successDetail) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).then(function() { @@ -3920,7 +4018,7 @@

Keyboard shortcuts

var heroTitle = document.getElementById('sn-event-title-hero'); if (heroTitle) heroTitle.textContent = EVENT_TITLE; var heroMeta = document.getElementById('sn-event-hero-meta'); - if (heroMeta) heroMeta.innerHTML = 'Disposable event brain · 0 joined · code ' + escapeHtml(EVENT_ROOM_CODE); + if (heroMeta) heroMeta.textContent = 'Live event log · public wiki when it ends'; var peopleSub = document.getElementById('menu-people-sub'); if (peopleSub) peopleSub.textContent = 'Live members'; var mode = document.getElementById('ev-mode-label'); @@ -3937,19 +4035,23 @@

Keyboard shortcuts

} } -function buildNodeBenchEventPrivateUrl() { +function _privateHandoffRoomCode() { var roomCode = EVENT_ROOM_CODE; try { var room = getRoomContext(); roomCode = (room && room.roomCode) || roomCode; } catch (e) {} - // INTERIM (honesty): the cross-domain `/events//private` receiving route - // + its handoff token don't exist yet (that's the gated cross-domain bridge - // follow-up). Pointing here previously produced a hard 404. Until the real - // tokenized private route ships, target the ALREADY-SHIPPED `/scratchnode-events` - // surface — an honest landing (it resolves session / shows a sign-in prompt), - // never a 404. The event context rides along so the real private route can - // consume it once built. + return roomCode; +} + +// FALLBACK (honesty): when we CANNOT mint a handoff token — no live client, the +// guest isn't a member, or the mint call fails — we MUST NOT dead-end on a +// tokenless `/events//private` (it would render a "missing token" state) +// and we MUST NOT put the sn_session_id in the URL. Instead fall back to the +// ALREADY-SHIPPED `/scratchnode-events` landing, which resolves session / +// shows a sign-in prompt. The event context rides along; NO session id, NO token. +function buildNodeBenchEventPrivateUrl() { + var roomCode = _privateHandoffRoomCode(); return WORKSPACE_BASE_URL + '/scratchnode-events?source=scratchnode' + '&event=' + encodeURIComponent(EVENT_SLUG) + @@ -3960,18 +4062,66 @@

Keyboard shortcuts

'&return=' + encodeURIComponent(EVENT_URL); } +// REAL tokenized handoff URL. Only the OPAQUE token travels — never the +// sn_session_id. Built only AFTER a successful mintEventHandoffToken. +function buildNodeBenchTokenizedPrivateUrl(token) { + var roomCode = _privateHandoffRoomCode(); + return WORKSPACE_BASE_URL + + '/events/' + encodeURIComponent(EVENT_SLUG) + '/private' + + '?token=' + encodeURIComponent(token) + + '&source=scratchnode' + + '&room=' + encodeURIComponent(roomCode); +} + function buildNodeBenchSignInUrl(targetUrl) { var target = targetUrl || buildNodeBenchEventPrivateUrl(); return WORKSPACE_BASE_URL + '/sign-in?return=' + encodeURIComponent(target) + '&intent=save-private-notes'; } +// Mint an opaque, event-scoped, READ-ONLY handoff token via the live Convex +// client, then navigate cross-domain to the REAL tokenized `/events//private` +// route. SECURITY: only the opaque token is ever placed in the URL — the +// sn_session_id (a permanent owner key) NEVER leaves this origin. If minting is +// impossible (no live client / not a member / mint error), fall back to the +// honest `/scratchnode-events` landing — never a 404, never a tokenless dead-end. function openNodeBenchPrivateHandoff() { closeMenu(); closeSheet(); - // Navigate straight to the shipped `/scratchnode-events` surface — it resolves - // session / shows its own sign-in prompt. (Previously wrapped a dead - // `/events//private` target in `/sign-in?return=…`, which 404'd.) - window.location.assign(buildNodeBenchEventPrivateUrl()); + + var live = window._sn_live; + var canMint = !!(live && live.client && live.slug && live.sessionId); + if (!canMint) { + // No live room session to prove membership with — honest fallback. + window.location.assign(buildNodeBenchEventPrivateUrl()); + return; + } + + if (typeof toast === 'function') { + toast('Bringing your notes to NodeBench…', 'Creating a secure one-time link.'); + } + + return Promise.resolve() + .then(function () { + return live.client.mutation('scratchnodeHandoff:mintEventHandoffToken', { + slug: live.slug, + sessionId: live.sessionId, + }); + }) + .then(function (res) { + var token = res && res.token; + if (!token) throw new Error('no_token'); + // Navigate to the REAL tokenized route. Only the opaque token travels. + window.location.assign(buildNodeBenchTokenizedPrivateUrl(token)); + }) + .catch(function (e) { + // Fail honest: mint denied (not a member / rate limited / backend down). + // Never expose the session id; never 404. Land on /scratchnode-events. + console.debug('[scratchnode] handoff mint failed, honest fallback:', (e && e.message) || e); + if (typeof toast === 'function') { + toast('Opening NodeBench', 'Sign in there to keep your private notes.'); + } + window.location.assign(buildNodeBenchEventPrivateUrl()); + }); } // ── Debug-gate flag for prototype helpers ── @@ -3991,14 +4141,14 @@

Keyboard shortcuts

document.body.setAttribute('data-mode', goingPrivate ? 'private' : 'public'); var input = document.getElementById('ci'); input.placeholder = goingPrivate - ? 'Private note… saves only to your notebook' - : 'Public chat… or /ask for a sourced answer'; + ? 'Save a private note…' + : 'Message or /ask…'; // Sync the mode badge next to the lock var modeLabel = document.getElementById('c-mode-label'); if (modeLabel) modeLabel.textContent = goingPrivate ? 'Private note' : 'Public room'; // Sync the helpline privacy state var privacyState = document.getElementById('c-privacy-state'); - if (privacyState) privacyState.textContent = goingPrivate ? '🔒 private — saves to your notebook' : '🔓 public — everyone in the room sees this'; + if (privacyState) privacyState.textContent = goingPrivate ? 'Private 🔒' : 'Public'; haptic(8); input.focus(); } @@ -4078,6 +4228,58 @@

Keyboard shortcuts

var m = String(n.getMinutes()).padStart(2, '0'); return (h > 12 ? h - 12 : (h || 12)) + ':' + m + (h >= 12 ? 'p' : 'a'); } + +// Manual event-log spots: typed by attendees/hosts, never inferred from GPS. +// This keeps location context lightweight for live events without geofencing. +var MANUAL_LOCATION_SPOTS = [ + { label: 'Booth 12', pattern: /\bbooth\s*12\b/i }, + { label: 'Lobby', pattern: /\blobby\b/i }, + { label: 'Panel Room A', pattern: /\bpanel\s+room\s+a\b/i }, + { label: 'Investor Lounge', pattern: /\binvestor\s+lounge\b/i }, + { label: 'Afterparty', pattern: /\bafterparty\b/i } +]; +function detectManualLocationSpot(text) { + var value = String(text || ''); + for (var i = 0; i < MANUAL_LOCATION_SPOTS.length; i++) { + if (MANUAL_LOCATION_SPOTS[i].pattern.test(value)) return MANUAL_LOCATION_SPOTS[i].label; + } + return ''; +} +function renderManualLocationSpot(row, text) { + if (!row) return ''; + var spot = detectManualLocationSpot(text); + if (!spot) return ''; + row.setAttribute('data-location-spot', spot); + var body = row.querySelector('.row-body'); + if (!body || body.querySelector('.sn-location-spot')) return spot; + var chip = document.createElement('span'); + chip.className = 'sn-location-spot'; + chip.setAttribute('data-location-spot', spot); + chip.textContent = 'at ' + spot; + body.appendChild(chip); + return spot; +} +// Public photo evidence is a typed event-log marker, not an upload pipeline. +// Real file/media handling belongs behind owner/workspace policy; this chip only +// marks explicitly named public evidence such as "photo: booth placard". +function detectPublicPhotoEvidence(text) { + var value = String(text || ''); + return /\b(photo|screenshot|image|pic)\s*:/i.test(value); +} +function renderPublicPhotoEvidence(row, text) { + if (!row || !detectPublicPhotoEvidence(text)) return false; + row.setAttribute('data-event-log-media', 'photo'); + var body = row.querySelector('.row-body'); + if (!body || body.querySelector('.sn-photo-evidence')) return true; + var chip = document.createElement('span'); + chip.className = 'sn-photo-evidence'; + chip.setAttribute('data-event-log-media', 'photo'); + chip.textContent = 'photo evidence'; + body.appendChild(chip); + return true; +} +window.detectPublicPhotoEvidence = detectPublicPhotoEvidence; +window.renderPublicPhotoEvidence = renderPublicPhotoEvidence; function _clearComposer() { var input = document.getElementById('ci'); input.value = ''; @@ -4154,6 +4356,7 @@

Keyboard shortcuts

row.setAttribute('data-mid', mid); row.innerHTML = '' + time + '
' + identity.displayName + '
'; row.querySelector('.row-text').textContent = (intent.isAsk ? '/ask ' : '') + intent.clean; + renderManualLocationSpot(row, intent.clean); // Reply context — if user is replying, insert a row-replying badge BEFORE the name if (room.replyingToMid) { var parent = document.querySelector('[data-mid="' + room.replyingToMid + '"]'); @@ -4893,7 +5096,7 @@

Keyboard shortcuts

'
' + renderNotesEditor() + '
' + '' + '

Continue privately

' + - '

Open NodeBench as the private handoff: public wiki context plus your private notebook for research, reports, and follow-ups.

' + + '

Open NodeBench as the private handoff: public wiki context plus your private notebook for deeper research, reports, and follow-ups across people, companies, topics, and anchors.

' + '' + '
' + buildNodeBenchEventPrivateUrl() + '
'; }, @@ -4932,7 +5135,7 @@

Keyboard shortcuts

// ── Center: article ── h.push('
'); h.push(''); - h.push('

AI Infra Summit · Wiki

'); + h.push('

AI Infra Summit · Wiki

'); h.push('

Published v1 — regenerated each round from public panel transcripts, /ask conversations, and verified sources. 318 attendees, 47 questions answered, 38 sources cited.

'); h.push('

Overview #

'); @@ -6048,6 +6251,15 @@

Keyboard shortcuts

{ name: 'ScratchNode', role: 'agent', color: '#d97757', initial: '🤖' } ]; +// Render company/topic tags as event-log chips. They are typed by the user, +// public-only when sent as public chat, and never inferred from private notes. +function renderEventLogTags(safeHtml) { + return safeHtml.replace(/(^|[\s(])#([A-Za-z][A-Za-z0-9_-]{1,40})\b/g, function(_, prefix, tag) { + var canonical = tag.toLowerCase(); + return prefix + '#' + tag + ''; + }); +} + // Render @mentions as accessible buttons (keyboard-focusable, aria-labeled). // Click delegation handled by document-level listener — no inline onclick. function renderMentions(text) { @@ -6061,7 +6273,7 @@

Keyboard shortcuts

'' ); }); - return safe; + return renderEventLogTags(safe); } // Delegated click + keyboard handler for any .mention button (works for // dynamically-injected mentions, agent-card mentions, and notes-editor mentions). @@ -6582,7 +6794,7 @@

Keyboard shortcuts

function _startLiveCueAutoRefresh() { if (window._live_assist._refreshTimer) return; window._live_assist._refreshTimer = setInterval(function() { - if (!window._live_assist || !window._live_assist.on) return; + if (document.hidden || !window._live_assist || !window._live_assist.on) return; var since = Date.now() - 30000; var cuePromise = _resolveLiveCueSource(since); @@ -6620,11 +6832,14 @@

Keyboard shortcuts

// Add a cue card. Returns the cue id. function pushLiveAssistCue(text, opts) { opts = opts || {}; + var cueId = _laNextId('cue'); var cue = { - id: _laNextId('cue'), + id: cueId, text: text, ts: Date.now(), - source: opts.source || 'agent' + source: opts.source || 'agent', + skill: opts.skill || 'meeting-live-cue', + traceRef: 'trace_' + cueId }; window._live_assist.cues.unshift(cue); // newest on top // Per spec: 1-3 cards visible. Hard cap at 3 to enforce reduction @@ -6634,20 +6849,19 @@

Keyboard shortcuts

if (typeof window.recordAgentOutputEnvelope === 'function') { var trigger = opts.trigger || _inferLiveCueTrigger(text); var l3 = opts.l3 || _inferLiveCueL3(text); - var traceRef = 'trace_' + cue.id; window.recordAgentOutputEnvelope({ id: 'out_' + cue.id, l1: 'private_memory', l2: 'live_cue', l3: l3, - target: { eventId: 'evt_ai_infra_summit', messageId: opts.messageId || undefined, traceId: traceRef }, + target: { eventId: 'evt_ai_infra_summit', messageId: opts.messageId || undefined, traceId: cue.traceRef }, visibility: opts.visibility || 'private', sourceRefs: opts.sourceRefs || ['event:evt_ai_infra_summit'], citationRefs: opts.citationRefs || [], - traceRef: traceRef, + traceRef: cue.traceRef, producedBy: { runId: 'run_' + cue.id, - skill: opts.skill || 'meeting-live-cue', + skill: cue.skill, toolChain: opts.toolChain || ['retrieve_event_context'] }, version: { memorySnapshotVersion: 1 }, @@ -6853,20 +7067,73 @@

Keyboard shortcuts

.replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } +function buildLiveAssistFollowUpNote(cue) { + var text = cue && cue.text ? String(cue.text) : 'Follow up on this event cue'; + var source = cue && cue.source ? String(cue.source) : ''; + var skill = cue && cue.skill ? String(cue.skill) : ''; + var traceRef = cue && cue.traceRef ? String(cue.traceRef) : ''; + var topic = window._live_assist && window._live_assist.currentTopic + ? window._live_assist.currentTopic + : null; + var context = window._live_assist && Array.isArray(window._live_assist.context) + ? window._live_assist.context.slice(0, 4) + : []; + var lines = [ + 'Follow-up: ' + text, + '', + 'Why it matters: Deepen this after the event in NodeBench with the public wiki plus your private notes.', + 'Next step: Ask for the concrete decision, metric, owner, or source behind this cue.', + 'Evidence to capture: quote, speaker/company, promised artifact, and deadline.', + 'NodeBench packet: people, companies, topics, anchors, source refs, and open questions.', + 'Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer.' + ]; + if (topic && topic.text) lines.push('Event topic: ' + topic.text + (topic.meta ? ' - ' + topic.meta : '')); + if (context.length) lines.push('Context: ' + context.join(', ')); + if (source) lines.push('Cue source: ' + source); + if (skill) lines.push('Cue skill: ' + skill); + if (traceRef) lines.push('Cue trace: ' + traceRef); + lines.push('Visibility: private follow-up note; not public chat or public /ask.'); + return lines.join('\n'); +} +function saveLiveAssistPrivateNote(text, source) { + var room = typeof getRoomContext === 'function' ? getRoomContext() : null; + var intent = { + clean: text, + isAsk: false, + kind: 'note', + visibility: 'private', + eventMode: document.body.getAttribute('data-event-mode') || 'event', + timestamp: Date.now(), + anchor: typeof buildPrivateNoteAnchor === 'function' ? buildPrivateNoteAnchor(room) : null + }; + if (typeof window.savePrivateNote === 'function') { + window.savePrivateNote(intent); + } else if (typeof savePrivateNote === 'function') { + savePrivateNote(intent); + } + if (typeof laRecordNote === 'function') { + try { laRecordNote(text, source || 'cue'); } catch (e) {} + } + return intent; +} function _laCueAction(action, cueId) { var cue = (window._live_assist.cues || []).find(function(c) { return c.id === cueId; }); if (!cue) return; if (action === 'save') { + saveLiveAssistPrivateNote('Cue: ' + cue.text, 'cue'); if (typeof toast === 'function') { try { toast('🔒 Cue saved as private note', cue.text); } catch (e) {} } - laRecordNote(cue.text, 'cue'); } else if (action === 'ask-private') { var input = document.getElementById('ci'); if (input) { input.value = '/ask private ' + cue.text; + input.dispatchEvent(new Event('input', { bubbles: true })); + try { input.setSelectionRange(input.value.length, input.value.length); } catch (e) {} input.focus(); } } else if (action === 'followup') { - if (typeof toast === 'function') { try { toast('Follow-up queued', cue.text); } catch (e) {} } + var followUpText = buildLiveAssistFollowUpNote(cue); + saveLiveAssistPrivateNote(followUpText, 'follow-up'); + if (typeof toast === 'function') { try { toast('Follow-up queued privately', cue.text); } catch (e) {} } } } @@ -6940,16 +7207,49 @@

Keyboard shortcuts

} else if (eventMode === 'work' && bodyMode === 'public') { placeholder = 'Visible to meeting room'; } else if (eventMode === 'event' && bodyMode === 'private') { - placeholder = 'Save a private note (only you can see this)'; + placeholder = 'Save a private note…'; } else { // event + public (default) - placeholder = 'Chat with the room. /ask for sourced answers. 🔒 for private.'; + placeholder = 'Message or /ask…'; } ci.setAttribute('placeholder', placeholder); } // Re-run on resize so mode-driven placeholder updates stay in sync after layout changes. if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('resize', _updateComposerPlaceholder); +// ─── Keyboard-aware composer (mobile) ─────────────────────────────── +// When the on-screen keyboard opens, the visual viewport shrinks while the +// layout viewport (window.innerHeight) does not. We measure that delta and +// pin the fixed composer above the keyboard via --keyboard-offset, and flag +// data-input-focused so the footer + welcome banner collapse while typing. +// This kills the "footer leaking behind the keyboard" issue on phones. +(function () { + try { + var ci = document.getElementById('ci'); + var root = document.documentElement; + var vv = window.visualViewport || null; + function syncKeyboard() { + if (!vv) return; + var offset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)); + root.style.setProperty('--keyboard-offset', offset + 'px'); + } + if (vv) { + vv.addEventListener('resize', syncKeyboard); + vv.addEventListener('scroll', syncKeyboard); + } + if (ci) { + ci.addEventListener('focus', function () { + document.body.setAttribute('data-input-focused', 'true'); + syncKeyboard(); + }); + ci.addEventListener('blur', function () { + document.body.removeAttribute('data-input-focused'); + root.style.setProperty('--keyboard-offset', '0px'); + }); + } + } catch (e) { /* visualViewport unsupported — composer stays pinned to bottom */ } +})(); + // Observe data-mode changes so placeholder updates on lock toggle (function() { var body = document.body; @@ -7145,6 +7445,7 @@

Keyboard shortcuts

window.laCompleteShorthand = laCompleteShorthand; window.laClearShorthand = laClearShorthand; window.laRecordNote = laRecordNote; +window.saveLiveAssistPrivateNote = saveLiveAssistPrivateNote; window.setEventMode = setEventMode; window.setCaptureLevel = setCaptureLevel; window.openModePicker = openModePicker; @@ -7255,7 +7556,7 @@

Keyboard shortcuts

const intent = window.parseComposerIntent && window.parseComposerIntent(input.value); if (!intent) { input.focus(); return; } // Private notes stay 100% prototype-side and never touch Convex — pass through. - if (intent.visibility === 'private') { return _preInitSend.call(this); } + if (intent.visibility === 'private' || intent.eventMode === 'sensitive') { return _preInitSend.call(this); } window._sn_pendingSends.push(input.value); input.value = ''; if ('ontouchstart' in window || window.innerWidth <= 720) input.blur(); @@ -7388,6 +7689,25 @@

Keyboard shortcuts

row.querySelector('.row-time').textContent = fmtTime(msg.createdAt); row.querySelector('.row-name').textContent = msg.displayName || 'Anonymous'; row.querySelector('.row-text').textContent = (msg.kind === 'ask' ? '/ask ' : '') + msg.text; + if (msg.replyToMessageId) { + row.setAttribute('data-reply-to-message-id', msg.replyToMessageId); + const parent = document.querySelector('[data-mid="' + msg.replyToMessageId + '"]'); + if (parent) { + const parentName = parent.querySelector('.row-name') ? parent.querySelector('.row-name').textContent : ''; + const parentText = parent.querySelector('.row-text') ? parent.querySelector('.row-text').textContent : ''; + const quote = parentText.length > 50 ? parentText.slice(0, 50) + '...' : parentText; + const rb = document.createElement('div'); + rb.className = 'row-replying'; + rb.innerHTML = 'replying to '; + rb.querySelector('.name').textContent = parentName || 'someone'; + rb.querySelector('.quote').textContent = '"' + quote + '"'; + const body = row.querySelector('.row-body'); + const nameEl = body && body.querySelector('.row-name'); + if (body && nameEl) body.insertBefore(rb, nameEl); + } + } + renderManualLocationSpot(row, msg.text); + if (window.renderPublicPhotoEvidence) window.renderPublicPhotoEvidence(row, msg.text); feedEl.appendChild(row); row.scrollIntoView({ behavior: 'smooth', block: 'end' }); }; @@ -7499,6 +7819,21 @@

Keyboard shortcuts

if (!intent) { input.focus(); return; } const submittedValue = input.value; + // Sensitive mode is manual-only: keep it on the private-save path. + if (intent.eventMode === 'sensitive') { + const room = getRoomContext(); + intent.anchor = buildPrivateNoteAnchor(room); + intent.visibility = 'private'; + savePrivateNote(intent); + if (typeof toast === 'function') { + try { toast('Sensitive mode', 'Saved locally. No agent call was made.'); } catch (e) {} + } + if (room.replyingToMid && typeof cancelReply === 'function') cancelReply(); + input.value = ''; + if ('ontouchstart' in window || window.innerWidth <= 720) input.blur(); + return; + } + // Private mode stays out of liveEventMessages. The savePrivateNote wrapper // below mirrors it into userNotes with ownerKey validation. if (intent.visibility === 'private') { @@ -7508,12 +7843,14 @@

Keyboard shortcuts

// Public chat or /ask — send to Convex. The agent answer for /ask is Phase 2; // for now the message just shows up in the feed. const liveName = (window._userName && String(window._userName).trim()) || 'Anonymous Guest'; + const room = getRoomContext(); const messagePayload = { eventId, sessionId, displayName: liveName, text: intent.clean, kind: intent.kind === 'agent_ask' ? 'ask' : 'chat', + replyToMessageId: room.replyingToMid || undefined, }; const postMessage = () => client.mutation('events:sendMessage', messagePayload); const runAskIfNeeded = (res) => { @@ -7570,6 +7907,7 @@

Keyboard shortcuts

// Clear composer; Convex subscription will render the new row when it lands. input.value = ''; + if (room.replyingToMid && typeof cancelReply === 'function') cancelReply(); if ('ontouchstart' in window || window.innerWidth <= 720) input.blur(); }; // Make sure both global aliases stay in sync (existing onclick handlers). @@ -8112,6 +8450,7 @@

Keyboard shortcuts

// ─── Presence heartbeat — every 30s ───────────────────────────────── setInterval(() => { + if (document.hidden) return; client.mutation('events:heartbeat', { eventId, sessionId }).catch(() => {}); }, 30_000); @@ -9730,9 +10069,10 @@

Keyboard shortcuts

'
auto-generated • ' + _hhmm() + '
' + '

"Healthcare voice agents need sub-350ms round-trip. Orbital Labs is the eval-infra reference. MCP auth Q3 2026 shipping with OAuth 2.1 scopes."

' + '' + - '
' + + '
' + '
🌟 daily brief — delta
' + '
3 new entities since yesterday
' + + '
What changed: NodeBench read the event wiki and public sources as the public artifact for this report. Why it matters: Private notes stay workspace-only while the owner gets deeper follow-ups in NodeBench.
' + '
From the AI Infra Summit event:
  • OrbitMed (founder: Sarah Kim) — new entity
  • Orbital Labs (founder: Alex Chen) — signal: Series A closed, 230+ benchmarks
  • Northbay Health CIO Marcus Reed — new contact
' + '
' + '
'; @@ -10406,10 +10746,27 @@

Keyboard shortcuts

fromDemo('DEMO-013', 'SN-LIVE-012', 'published wiki excludes private notes'); add('SN-LIVE-013', 'share URL uses scratchnode.live-compatible public event URL', /\/e\//.test(EVENT_URL) && !/scratchnode\.com/i.test(EVENT_URL), EVENT_URL); fromDemo('DEMO-014', 'SN-LIVE-014', 'guest can sign in and preserve notes'); - var handoffUrl = buildNodeBenchEventPrivateUrl(); - add('SN-LIVE-015', 'NodeBench handoff targets a SHIPPED route (no 404) + carries event continuation context', - /nodebenchai\.com/.test(handoffUrl) && /\/scratchnode-events/.test(handoffUrl) && !/\/events\/[^/]+\/private/.test(handoffUrl) && /continuation=private-notes/.test(handoffUrl) && /publicArtifact=event-wiki/.test(handoffUrl), - handoffUrl); + // SN-LIVE-015 — the REAL tokenized private-notes handoff (roadmap #4). + // The tokenized URL is the success path; /scratchnode-events is the honest + // fallback. Use a SENTINEL token (NOT a real session) so the QA never mints. + var SAMPLE_TOKEN = 'qa-sentinel-token-0000000000'; + var tokenizedUrl = buildNodeBenchTokenizedPrivateUrl(SAMPLE_TOKEN); + var fallbackUrl = buildNodeBenchEventPrivateUrl(); + var sid = ''; + try { sid = (window._sn_live && window._sn_live.sessionId) || localStorage.getItem('sn_session_id') || ''; } catch (e) {} + // INVARIANT: the session id must NEVER appear in EITHER URL. Only the opaque + // token (or the tokenless honest fallback) may travel cross-domain. + var sessionLeaks = !!sid && (tokenizedUrl.indexOf(sid) !== -1 || fallbackUrl.indexOf(sid) !== -1); + add('SN-LIVE-015', 'Private-notes handoff: tokenized real route + honest fallback + NO session-id leak', + // (a) tokenized URL hits the REAL receiving route with the opaque token + /nodebenchai\.com/.test(tokenizedUrl) && /\/events\/[^/]+\/private\?/.test(tokenizedUrl) && /token=qa-sentinel-token-0000000000/.test(tokenizedUrl) && + // (b) fallback stays the honest shipped /scratchnode-events landing (no 404, no tokenless dead-end) + /\/scratchnode-events/.test(fallbackUrl) && /continuation=private-notes/.test(fallbackUrl) && /publicArtifact=event-wiki/.test(fallbackUrl) && + // (c) openNodeBenchPrivateHandoff MINTS before navigating (no raw session id in the URL path) + /mintEventHandoffToken/.test(String(openNodeBenchPrivateHandoff)) && + // (d) HARD INVARIANT — session id leaks into neither URL + !sessionLeaks, + 'tokenized=' + tokenizedUrl + ' | fallback=' + fallbackUrl + ' | sessionLeaks=' + sessionLeaks); var passed = results.filter(function(r) { return r.pass; }).length; var failed = results.length - passed; return { passed: passed, failed: failed, total: results.length, results: results, demo: demoSummary }; diff --git a/qa/run_demo_full.md b/qa/run_demo_full.md new file mode 100644 index 000000000..dc3862234 --- /dev/null +++ b/qa/run_demo_full.md @@ -0,0 +1,23 @@ +# ScratchNode Full Demo Regression Oracle + +The full demo is the product-story oracle. A change is not an improvement if it breaks this loop. + +Expected story: + +1. Participants enter the event. +2. Public messages appear. +3. A `/ask` parent row appears. +4. A sourced answer appears under the parent question. +5. The answer trace states no private notes were used. +6. A private note is saved outside the public feed. +7. Attendee suggests an answer for FAQ. +8. Host promotes the answer. +9. Public wiki publishes without private notes. +10. User opens NodeBench handoff. +11. NodeBench shows event artifact plus private-note continuation. + +Verification command: + +```bash +npm run scratchnode:launch:goal +``` diff --git a/scripts/repo/__tests__/scratchnodePublicExport.test.ts b/scripts/repo/__tests__/scratchnodePublicExport.test.ts new file mode 100644 index 000000000..ba656fb06 --- /dev/null +++ b/scripts/repo/__tests__/scratchnodePublicExport.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, test } from "vitest"; + +const repoRoot = path.resolve(__dirname, "..", "..", ".."); +const exportScript = path.join(repoRoot, "scripts", "repo", "export-scratchnode-live-public.mjs"); + +function runNodeScript(scriptPath: string, args: string[], cwd: string) { + const result = spawnSync(process.execPath, [scriptPath, ...args], { + cwd, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error( + [ + `Command failed: node ${path.relative(repoRoot, scriptPath)} ${args.join(" ")}`.trim(), + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join("\n"), + ); + } + return result; +} + +describe("scratchnode public export", () => { + test("generates an export whose public and owner-only event-log projections stay separated", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "scratchnode-public-export-")); + const outDir = path.join(tmpRoot, "export"); + try { + runNodeScript(exportScript, ["--out", outDir], repoRoot); + + const readme = fs.readFileSync(path.join(outDir, "README.md"), "utf8"); + const invariants = fs.readFileSync(path.join(outDir, "docs", "invariants.md"), "utf8"); + const contract = JSON.parse( + fs.readFileSync(path.join(outDir, "contracts", "scratchnode-live-api.json"), "utf8"), + ) as { + surface: string; + framing: string; + eventLogProjections: { + publicEventLogJson: { visibility: string; includes: string[]; excludes: string[] }; + ownerPrivateNoteProjection: { visibility: string; includes: string[]; excludes: string[] }; + }; + }; + + expect(readme).toMatch(/open-source event log assistant/i); + expect(readme).toMatch(/memory layer for live events/i); + expect(readme).not.toMatch(/\bproduction[-\s]+(?:ready|grade)\b/i); + expect(invariants).toContain("Public event-log JSON contains public room moments only."); + expect(invariants).toContain("Owner-only private note projections are separate from public event-log JSON."); + + expect(contract.surface).toBe("open-source event log assistant"); + expect(contract.framing).toBe("memory layer for live events"); + expect(contract.eventLogProjections.publicEventLogJson).toEqual({ + visibility: "public", + includes: expect.arrayContaining([ + "event metadata", + "public chat messages", + "public /ask questions and answers", + "host-promoted FAQ/wiki sections", + "public source references", + "typed manual location spots", + ]), + excludes: expect.arrayContaining([ + "private notes", + "owner keys", + "session ids", + "handoff tokens", + "NodeBench workspace artifacts", + ]), + }); + expect(contract.eventLogProjections.ownerPrivateNoteProjection).toEqual({ + visibility: "owner-only", + includes: expect.arrayContaining([ + "owner private notes", + "private note anchors", + "manual location spot anchors", + "private follow-ups", + "NodeBench handoff context", + ]), + excludes: expect.arrayContaining([ + "public wiki JSON", + "public /ask cache", + "public answer traces", + "other attendees' notes", + ]), + }); + + runNodeScript(path.join(outDir, "scripts", "verify-public-export.mjs"), [], outDir); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/repo/checkAugmentUploadScope.ps1 b/scripts/repo/checkAugmentUploadScope.ps1 index d99def793..b10f6323e 100644 --- a/scripts/repo/checkAugmentUploadScope.ps1 +++ b/scripts/repo/checkAugmentUploadScope.ps1 @@ -110,6 +110,36 @@ if (Test-Path -LiteralPath $augmentIgnorePath) { $patterns = @(Get-Content -LiteralPath $augmentIgnorePath) } +$criticalIgnoreProbePaths = @( + ".git/config", + "node_modules/example.js", + ".tmp/workspace-housekeeping-loop.json", + ".tmp/local-history-map-reduce.json", + ".tmp/augment-upload-scope.json", + ".tmp/workspace-footprint.json", + ".tmp/workspace-housekeeping-verification.json", + ".tmp/workspace-housekeeping-self-test.json", + ".worktrees/example/file.txt", + ".claude/worktrees/example/file.txt", + ".claude/projects/example.json", + ".overstory/example.json", + ".serena/example.json", + "test-results/example.json", + "playwright-report/index.html", + "scripts/eval-harness/results/example.json" +) + +$criticalIgnoreProbes = @( + $criticalIgnoreProbePaths | ForEach-Object { + [pscustomobject]@{ + path = $_ + augmentIgnored = Test-AugmentIgnored $_ $patterns + } + } +) +$criticalIgnoreProbeFailures = @($criticalIgnoreProbes | Where-Object { -not $_.augmentIgnored }) +$criticalIgnoreProbesPassed = $criticalIgnoreProbeFailures.Count -eq 0 + $includedTracked = New-Object System.Collections.Generic.List[string] $excludedTracked = New-Object System.Collections.Generic.List[string] foreach ($file in $trackedFiles) { @@ -131,13 +161,15 @@ foreach ($file in $untrackedFiles) { } $candidateFiles = $includedTracked.Count + $includedUntracked.Count -$passed = $candidateFiles -le $Threshold +$candidateCountPassed = $candidateFiles -le $Threshold +$passed = $candidateCountPassed -and $criticalIgnoreProbesPassed $report = [pscustomobject]@{ generatedAt = (Get-Date).ToUniversalTime().ToString("o") repo = $repoRoot threshold = $Threshold passed = $passed + candidateCountPassed = $candidateCountPassed candidateFiles = $candidateFiles trackedFiles = $trackedFiles.Count trackedIncluded = $includedTracked.Count @@ -145,19 +177,26 @@ $report = [pscustomobject]@{ untrackedFiles = $untrackedFiles.Count untrackedIncluded = $includedUntracked.Count untrackedExcludedByAugmentignore = $excludedUntracked.Count + criticalIgnoreProbesPassed = $criticalIgnoreProbesPassed + criticalIgnoreProbeFailures = $criticalIgnoreProbeFailures.Count augmentIgnorePath = if (Test-Path -LiteralPath $augmentIgnorePath) { $augmentIgnorePath } else { $null } samples = [pscustomobject]@{ trackedExcludedByAugmentignore = @($excludedTracked | Select-Object -First $SampleLimit) untrackedIncluded = @($includedUntracked | Select-Object -First $SampleLimit) untrackedExcludedByAugmentignore = @($excludedUntracked | Select-Object -First $SampleLimit) } + criticalIgnoreProbes = $criticalIgnoreProbes } $outPath = Join-Path $repoRoot $Out New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outPath -Encoding utf8 -$report | Select-Object generatedAt, repo, threshold, passed, candidateFiles, trackedFiles, trackedIncluded, trackedExcludedByAugmentignore, untrackedFiles, untrackedIncluded, untrackedExcludedByAugmentignore, augmentIgnorePath | ConvertTo-Json -Depth 4 +$report | Select-Object generatedAt, repo, threshold, passed, candidateCountPassed, candidateFiles, trackedFiles, trackedIncluded, trackedExcludedByAugmentignore, untrackedFiles, untrackedIncluded, untrackedExcludedByAugmentignore, criticalIgnoreProbesPassed, criticalIgnoreProbeFailures, augmentIgnorePath | ConvertTo-Json -Depth 4 -if (-not $passed) { +if (-not $candidateCountPassed) { throw "Augment upload candidate count $candidateFiles exceeds threshold $Threshold. Review .augmentignore and local history before opening this workspace in Augment." } + +if (-not $criticalIgnoreProbesPassed) { + throw "Critical Augment ignore probes failed. Review .augmentignore coverage for generated reports, local history, and worktrees." +} diff --git a/scripts/repo/export-scratchnode-live-public.mjs b/scripts/repo/export-scratchnode-live-public.mjs index cdbf7047d..5018a0c8a 100644 --- a/scripts/repo/export-scratchnode-live-public.mjs +++ b/scripts/repo/export-scratchnode-live-public.mjs @@ -127,9 +127,9 @@ function writeGeneratedFiles(copiedEntries) { function buildReadme() { return `# ScratchNode Live -**Live rooms that remember.** +**Open-source event log assistant for live rooms.** -ScratchNode is a public event-room prototype where people join with a code, chat normally, use \`/ask\` for sourced answers, and leave behind a public wiki. Private notes stay private and can sync into NodeBench later. +ScratchNode is an open-source event log assistant and memory layer for live events. People join with a code, chat normally, use \`/ask\` for sourced answers, and leave behind a public event wiki. Private notes stay private and can sync into NodeBench later. - No app required. - No account required for public room join. @@ -162,7 +162,7 @@ npm run dev ## Status -This is a sanitized public frontend split. The Convex backend, NodeBench workspace, MCP services, internal evals, and deploy orchestration remain in the private \`nodebench-ai\` monorepo. +This is a sanitized public frontend split and architecture reference. The Convex backend, NodeBench workspace, MCP services, internal evals, and deploy orchestration remain in the private \`nodebench-ai\` monorepo. See [docs/prototype-vs-production.md](docs/prototype-vs-production.md). @@ -260,7 +260,7 @@ function buildPackageJson() { version: "0.1.0", private: true, type: "module", - description: "Live event rooms that turn public chat and /ask answers into a wiki while private notes stay private.", + description: "Open-source event log assistant and memory layer for live events.", scripts: { dev: "vercel dev", verify: "npm run verify:static", @@ -361,6 +361,8 @@ These are product safety rules, not preferences. 5. Public traces show "No private notes used." 6. Demo automation runs only on \`/demo_ver*\`. 7. Missing live rooms show an honest missing-room state, not mock chat. +8. Public event-log JSON contains public room moments only. +9. Owner-only private note projections are separate from public event-log JSON. `; } @@ -382,6 +384,8 @@ This repo should stay focused on the public live room. Backend runtime changes h function buildApiContract() { return { + surface: "open-source event log assistant", + framing: "memory layer for live events", convexFunctions: [ "events:getEventBySlug", "events:joinEvent", @@ -411,6 +415,43 @@ function buildApiContract() { "public ask traces say no private notes used", "private note markers are owner-only", ], + eventLogProjections: { + publicEventLogJson: { + visibility: "public", + includes: [ + "event metadata", + "public chat messages", + "public /ask questions and answers", + "host-promoted FAQ/wiki sections", + "public source references", + "typed manual location spots", + ], + excludes: [ + "private notes", + "owner keys", + "session ids", + "handoff tokens", + "NodeBench workspace artifacts", + ], + }, + ownerPrivateNoteProjection: { + visibility: "owner-only", + includes: [ + "owner private notes", + "private note anchors", + "manual location spot anchors", + "private follow-ups", + "owner voice transcripts", + "NodeBench handoff context", + ], + excludes: [ + "public wiki JSON", + "public /ask cache", + "public answer traces", + "other attendees' notes", + ], + }, + }, }; } @@ -421,6 +462,7 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const required = [ + "README.md", "public/proto/home-v5.html", "public/proto/docs.html", "api/scratchnode-config.js", @@ -452,6 +494,35 @@ if (presentForbidden.length) { process.exit(1); } +const contract = JSON.parse(fs.readFileSync(path.join(root, "contracts/scratchnode-live-api.json"), "utf8")); +const readme = fs.readFileSync(path.join(root, "README.md"), "utf8"); +const positioningText = readme + "\\n" + JSON.stringify(contract); +if (!/open-source event log assistant/i.test(positioningText) || !/memory layer for live events/i.test(positioningText)) { + console.error("Public export positioning must say open-source event log assistant and memory layer for live events."); + process.exit(1); +} +if (/\\bproduction[-\\s]+(?:ready|grade)\\b/i.test(readme)) { + console.error("Public README must not claim final production status."); + process.exit(1); +} +const eventLog = contract.eventLogProjections || {}; +const publicLog = eventLog.publicEventLogJson || {}; +const privateProjection = eventLog.ownerPrivateNoteProjection || {}; +const requiredPublicExclusions = ["private notes", "owner keys", "session ids", "handoff tokens"]; +const missingPublicExclusions = requiredPublicExclusions.filter((entry) => !(publicLog.excludes || []).includes(entry)); +if (publicLog.visibility !== "public" || missingPublicExclusions.length) { + console.error("Public event-log projection contract is incomplete."); + process.exit(1); +} +const requiredPrivateIncludes = ["owner private notes", "private note anchors", "manual location spot anchors", "private follow-ups", "owner voice transcripts", "NodeBench handoff context"]; +const requiredPrivateExclusions = ["public wiki JSON", "public /ask cache", "public answer traces", "other attendees' notes"]; +const missingPrivateIncludes = requiredPrivateIncludes.filter((entry) => !(privateProjection.includes || []).includes(entry)); +const missingPrivateExclusions = requiredPrivateExclusions.filter((entry) => !(privateProjection.excludes || []).includes(entry)); +if (privateProjection.visibility !== "owner-only" || missingPrivateIncludes.length || missingPrivateExclusions.length) { + console.error("Owner-only private note projection contract is incomplete."); + process.exit(1); +} + console.log("ScratchNode public export verification passed."); `; writeText("scripts/verify-public-export.mjs", script); diff --git a/scripts/repo/mapReduceLocalHistory.ps1 b/scripts/repo/mapReduceLocalHistory.ps1 new file mode 100644 index 000000000..81da1ba11 --- /dev/null +++ b/scripts/repo/mapReduceLocalHistory.ps1 @@ -0,0 +1,286 @@ +param( + [switch]$ApplySafe, + [switch]$ApplyCleanWorktrees, + [string]$Out = ".tmp/local-history-map-reduce.json" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$repoRootFull = [System.IO.Path]::GetFullPath($repoRoot) + +function Normalize-RepoPath([string]$Path) { + return ($Path -replace "\\", "/").TrimEnd("/") +} + +function Get-RepoRelativePath([string]$Path) { + $full = [System.IO.Path]::GetFullPath($Path) + if ($full.Equals($repoRootFull, [System.StringComparison]::OrdinalIgnoreCase)) { + return "." + } + if ($full.StartsWith($repoRootFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + return Normalize-RepoPath $full.Substring($repoRootFull.Length + 1) + } + return $null +} + +function Assert-InRepo([string]$Path) { + $full = [System.IO.Path]::GetFullPath($Path) + if (-not $full.StartsWith($repoRootFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to modify path outside repo: $full" + } + return $full +} + +function New-Entry([string]$Bucket, [string]$Path, [string]$Reason, [hashtable]$Extra = @{}) { + $absolute = if ([System.IO.Path]::IsPathRooted($Path)) { [System.IO.Path]::GetFullPath($Path) } else { [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) } + $entry = [ordered]@{ + bucket = $Bucket + path = Normalize-RepoPath $Path + absolutePath = $absolute + reason = $Reason + } + foreach ($key in $Extra.Keys) { + $entry[$key] = $Extra[$key] + } + return [pscustomobject]$entry +} + +function Add-Entry($Buckets, $Entry) { + $Buckets[$Entry.bucket].Add($Entry) | Out-Null +} + +function Get-Worktrees { + $items = @() + $current = $null + foreach ($line in & git worktree list --porcelain) { + if ([string]::IsNullOrWhiteSpace($line)) { + if ($current) { $items += [pscustomobject]$current } + $current = $null + continue + } + if ($line.StartsWith("worktree ")) { + if ($current) { $items += [pscustomobject]$current } + $current = [ordered]@{ path = $line.Substring("worktree ".Length); locked = $false; branch = $null; head = $null } + continue + } + if (-not $current) { continue } + if ($line.StartsWith("branch ")) { + $current.branch = $line.Substring("branch ".Length) + } elseif ($line.StartsWith("HEAD ")) { + $current.head = $line.Substring("HEAD ".Length) + } elseif ($line.StartsWith("locked")) { + $current.locked = $true + } + } + if ($current) { $items += [pscustomobject]$current } + return $items +} + +function Invoke-GitQuiet([string[]]$Arguments) { + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& git @Arguments 2>$null) + $exitCode = $LASTEXITCODE + return [pscustomobject]@{ output = $output; exitCode = $exitCode } + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } +} + +function Test-WorktreeUsable([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $probe = Invoke-GitQuiet @("-C", $Path, "rev-parse", "--is-inside-work-tree") + return $probe.exitCode -eq 0 -and (($probe.output | Select-Object -First 1) -eq "true") +} + +function Test-WorktreeDirty([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $statusResult = Invoke-GitQuiet @("-C", $Path, "status", "--porcelain") + if ($statusResult.exitCode -ne 0) { return $false } + $status = @($statusResult.output) + return $status.Count -gt 0 +} + +function Test-CleanupLocked([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + if (-not $item) { return $true } + + $files = if ($item.PSIsContainer) { + @(Get-ChildItem -LiteralPath $Path -File -Recurse -Force -ErrorAction SilentlyContinue) + } else { + @($item) + } + + foreach ($file in $files) { + $stream = $null + try { + $stream = [System.IO.File]::Open($file.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None) + } catch { + return $true + } finally { + if ($stream) { $stream.Close() } + } + } + + return $false +} + +$buckets = [ordered]@{ + safe = [System.Collections.Generic.List[object]]::new() + caution = [System.Collections.Generic.List[object]]::new() + keep = [System.Collections.Generic.List[object]]::new() + external_report_only = [System.Collections.Generic.List[object]]::new() + nested_report_only = [System.Collections.Generic.List[object]]::new() +} + +$tmpKeep = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +@( + "augment-upload-scope.json", + "workspace-footprint.json", + "local-history-map-reduce.json", + "workspace-housekeeping-loop.json", + "workspace-housekeeping-verification.json", + "workspace-housekeeping-self-test.json" + "scratchnode-launch-goal-loop.json", + "scratchnode-launch-scan.json" +) | ForEach-Object { $tmpKeep.Add($_) | Out-Null } + +$tmpPath = Join-Path $repoRoot ".tmp" +if (Test-Path -LiteralPath $tmpPath) { + foreach ($child in Get-ChildItem -LiteralPath $tmpPath -Force -ErrorAction SilentlyContinue) { + if (-not $tmpKeep.Contains($child.Name)) { + $relativeChild = Join-Path ".tmp" $child.Name + if (Test-CleanupLocked $child.FullName) { + Add-Entry $buckets (New-Entry "keep" $relativeChild "locked generated .tmp child" @{ locked = $true }) + } else { + Add-Entry $buckets (New-Entry "safe" $relativeChild "generated .tmp child") + } + } + } +} + +foreach ($safeRoot in @("test-results", "playwright-report", "scripts/eval-harness/results")) { + $path = Join-Path $repoRoot $safeRoot + if (Test-Path -LiteralPath $path) { + foreach ($child in Get-ChildItem -LiteralPath $path -Force -ErrorAction SilentlyContinue) { + if ($child.Name -ne ".gitignore") { + Add-Entry $buckets (New-Entry "safe" (Join-Path $safeRoot $child.Name) "generated test/eval output") + } + } + } +} + +$stats = [ordered]@{ locked = 0; lockedAlive = 0; lockedStale = 0; dirty = 0; cleanLocked = 0; invalidRegistered = 0 } +$nested = [ordered]@{ registered = 0; existing = 0; missing = 0; invalid = 0; dirty = 0; locked = 0; lockedAlive = 0; lockedStale = 0 } +$external = [ordered]@{ registered = 0; existing = 0; missing = 0; invalid = 0; dirty = 0; locked = 0 } + +foreach ($wt in Get-Worktrees) { + $absolute = [System.IO.Path]::GetFullPath($wt.path) + $relative = Get-RepoRelativePath $absolute + $exists = Test-Path -LiteralPath $absolute + $isExternal = $null -eq $relative + $isNested = -not $isExternal -and ($relative -like ".worktrees/*" -or $relative -like ".claude/worktrees/*") + $isRequired = -not $isExternal -and $relative -eq ".worktrees/prod-parity-runtime" + $isUsable = Test-WorktreeUsable $absolute + $isDirty = if ($isUsable) { Test-WorktreeDirty $absolute } else { $false } + $isLocked = [bool]$wt.locked + + if ($exists -and -not $isUsable) { $stats.invalidRegistered += 1 } + if ($isDirty) { $stats.dirty += 1 } + if ($isLocked) { + $stats.locked += 1 + $stats.lockedStale += 1 + if (-not $isDirty) { $stats.cleanLocked += 1 } + } + + $entryPath = if ($relative) { $relative } else { $absolute } + $extra = @{ dirty = $isDirty; locked = $isLocked; lockAlive = $null; branch = $wt.branch; exists = $exists; gitUsable = $isUsable } + + if ($isExternal) { + $external.registered += 1 + if ($exists) { $external.existing += 1 } else { $external.missing += 1 } + if ($exists -and -not $isUsable) { $external.invalid += 1 } + if ($isDirty) { $external.dirty += 1 } + if ($isLocked) { $external.locked += 1 } + Add-Entry $buckets (New-Entry "external_report_only" $entryPath "external registered worktree; report only" $extra) + continue + } + + if ($isNested) { + $nested.registered += 1 + if ($exists) { $nested.existing += 1 } else { $nested.missing += 1 } + if ($exists -and -not $isUsable) { $nested.invalid += 1 } + if ($isDirty) { $nested.dirty += 1 } + if ($isLocked) { $nested.locked += 1; $nested.lockedStale += 1 } + } + + if ($relative -eq ".") { + Add-Entry $buckets (New-Entry "keep" $entryPath "primary worktree" $extra) + } elseif ($isRequired) { + Add-Entry $buckets (New-Entry "keep" $entryPath "required prod-parity worktree" $extra) + } elseif (-not $exists) { + Add-Entry $buckets (New-Entry "keep" $entryPath "missing registered worktree; inspect git metadata first" $extra) + } elseif (-not $isUsable) { + Add-Entry $buckets (New-Entry "keep" $entryPath "invalid registered worktree; inspect git metadata first" $extra) + } elseif ($isDirty) { + Add-Entry $buckets (New-Entry "keep" $entryPath "dirty registered worktree" $extra) + } elseif ($isLocked) { + Add-Entry $buckets (New-Entry "keep" $entryPath "locked registered worktree" $extra) + } elseif ($isNested) { + Add-Entry $buckets (New-Entry "caution" $entryPath "clean registered worktree; explicit prune only" $extra) + } else { + Add-Entry $buckets (New-Entry "keep" $entryPath "registered worktree outside cleanup roots" $extra) + } +} + +$actions = [ordered]@{ safeCleanupApplied = [bool]$ApplySafe; cleanWorktreePruneApplied = [bool]$ApplyCleanWorktrees; removedSafe = @(); skippedSafe = @(); prunedWorktrees = @() } + +if ($ApplySafe) { + foreach ($entry in @($buckets.safe)) { + $target = Assert-InRepo $entry.absolutePath + if (Test-Path -LiteralPath $target) { + try { + Remove-Item -LiteralPath $target -Recurse -Force + $actions.removedSafe += $entry.path + } catch { + $actions.skippedSafe += [pscustomobject]@{ + path = $entry.path + reason = "cleanup failed; preserving generated path" + error = $_.Exception.Message + } + } + } + } +} + +if ($ApplyCleanWorktrees) { + foreach ($entry in @($buckets.caution)) { + $target = Assert-InRepo $entry.absolutePath + if (Test-Path -LiteralPath $target) { + & git worktree remove --force $target | Out-Null + $actions.prunedWorktrees += $entry.path + } + } +} + +$report = [ordered]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + summary = [ordered]@{ + safe = [ordered]@{ entries = $buckets.safe.Count } + caution = [ordered]@{ entries = $buckets.caution.Count } + keep = [ordered]@{ entries = $buckets.keep.Count } + } + stats = $stats + nestedSummary = $nested + externalSummary = $external + buckets = $buckets + actions = $actions +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +[pscustomobject]$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $outPath -Encoding utf8 +[pscustomobject]$report | Select-Object generatedAt, repo, summary, stats, nestedSummary, externalSummary, actions | ConvertTo-Json -Depth 8 diff --git a/scripts/repo/runWorkspaceHousekeeping.ps1 b/scripts/repo/runWorkspaceHousekeeping.ps1 new file mode 100644 index 000000000..391489552 --- /dev/null +++ b/scripts/repo/runWorkspaceHousekeeping.ps1 @@ -0,0 +1,134 @@ +param( + [switch]$ApplyCleanWorktrees, + [string[]]$ProtectedPaths = @("public/proto/home-v5.html"), + [string]$Out = ".tmp/workspace-housekeeping-loop.json" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Invoke-JsonScript([string]$ScriptPath, [string[]]$Arguments = @()) { + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $ScriptPath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Script failed: $ScriptPath" + } + return ($output | Out-String).Trim() +} + +function Read-JsonFile([string]$Path) { + return Get-Content -Raw -LiteralPath (Join-Path $repoRoot $Path) | ConvertFrom-Json +} + +function Get-GitStatusLines { + return @(& git status --short --branch --untracked-files=all) +} + +function Test-GitDiffPath([string]$Path, [switch]$Cached) { + $arguments = @("diff", "--name-only") + if ($Cached) { + $arguments += "--cached" + } + $arguments += "--" + $arguments += $Path + return @(& git @arguments).Count -gt 0 +} + +function Get-ProtectedPathReport([string[]]$Paths) { + return @( + foreach ($path in $Paths) { + $unstagedDiff = Test-GitDiffPath $path + $stagedDiff = Test-GitDiffPath $path -Cached + [pscustomobject]@{ + path = $path + exists = Test-Path -LiteralPath (Join-Path $repoRoot $path) + unstagedDiff = $unstagedDiff + stagedDiff = $stagedDiff + clean = -not ($unstagedDiff -or $stagedDiff) + } + } + ) +} + +$footprintScript = Join-Path $scriptRoot "auditWorkspaceFootprint.ps1" +$augmentScript = Join-Path $scriptRoot "checkAugmentUploadScope.ps1" +$historyScript = Join-Path $scriptRoot "mapReduceLocalHistory.ps1" + +$statusBefore = Get-GitStatusLines +Invoke-JsonScript $footprintScript | Out-Null +Invoke-JsonScript $augmentScript | Out-Null +Invoke-JsonScript $historyScript | Out-Null + +$initialHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" +$safeBefore = [int]$initialHistory.summary.safe.entries +$removedSafe = @() +$skippedSafe = @() +$prunedWorktrees = @() + +if ($safeBefore -gt 0) { + Invoke-JsonScript $historyScript @("-ApplySafe") | Out-Null + $safeCleanupHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" + $removedSafe = @($safeCleanupHistory.actions.removedSafe) + $skippedSafe = @($safeCleanupHistory.actions.skippedSafe) +} + +if ($ApplyCleanWorktrees) { + Invoke-JsonScript $historyScript @("-ApplyCleanWorktrees") | Out-Null + $worktreeCleanupHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" + $prunedWorktrees = @($worktreeCleanupHistory.actions.prunedWorktrees) +} + +Invoke-JsonScript $historyScript | Out-Null + +$footprint = Read-JsonFile ".tmp/workspace-footprint.json" +$augment = Read-JsonFile ".tmp/augment-upload-scope.json" +$finalHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" +$statusAfter = Get-GitStatusLines +$protectedPathReport = @(Get-ProtectedPathReport $ProtectedPaths) +$protectedPathsClean = -not (@($protectedPathReport | Where-Object { -not $_.clean }).Count -gt 0) + +$report = [ordered]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + gitStatusBefore = $statusBefore + gitStatusAfter = $statusAfter + protectedPathsClean = $protectedPathsClean + protectedPaths = $protectedPathReport + footprint = [ordered]@{ + trackedFiles = $footprint.trackedFiles + detailedCounts = $footprint.detailedCounts + } + augmentScope = [ordered]@{ + threshold = $augment.threshold + passed = $augment.passed + candidateCountPassed = $augment.candidateCountPassed + candidateFiles = $augment.candidateFiles + trackedFiles = $augment.trackedFiles + trackedIncluded = $augment.trackedIncluded + trackedExcludedByAugmentignore = $augment.trackedExcludedByAugmentignore + untrackedIncluded = $augment.untrackedIncluded + untrackedExcludedByAugmentignore = $augment.untrackedExcludedByAugmentignore + criticalIgnoreProbesPassed = $augment.criticalIgnoreProbesPassed + criticalIgnoreProbeFailures = $augment.criticalIgnoreProbeFailures + } + initialHistory = $initialHistory.summary + finalHistory = $finalHistory.summary + finalStats = $finalHistory.stats + nestedSummary = $finalHistory.nestedSummary + externalSummary = $finalHistory.externalSummary + actions = [ordered]@{ + safeCleanupApplied = ($safeBefore -gt 0) + removedSafeCount = $removedSafe.Count + removedSafe = $removedSafe + skippedSafeCount = $skippedSafe.Count + skippedSafe = $skippedSafe + cleanWorktreePruneApplied = [bool]$ApplyCleanWorktrees + prunedWorktreeCount = $prunedWorktrees.Count + prunedWorktrees = $prunedWorktrees + } +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +[pscustomobject]$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $outPath -Encoding utf8 +[pscustomobject]$report | ConvertTo-Json -Depth 10 diff --git a/scripts/repo/testWorkspaceHousekeeping.ps1 b/scripts/repo/testWorkspaceHousekeeping.ps1 new file mode 100644 index 000000000..466f45382 --- /dev/null +++ b/scripts/repo/testWorkspaceHousekeeping.ps1 @@ -0,0 +1,126 @@ +param( + [string]$ProbeName = "housekeeping-self-test-safe-probe.txt", + [string]$Out = ".tmp/workspace-housekeeping-self-test.json" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Normalize-RepoPath([string]$Path) { + return ($Path -replace "\\", "/").TrimEnd("/") +} + +function Invoke-JsonScript([string]$ScriptPath, [string[]]$Arguments = @()) { + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $ScriptPath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Script failed: $ScriptPath" + } + return ($output | Out-String).Trim() +} + +function Read-JsonFile([string]$Path) { + return Get-Content -Raw -LiteralPath (Join-Path $repoRoot $Path) | ConvertFrom-Json +} + +function Add-Message([System.Collections.Generic.List[string]]$Messages, [string]$Message) { + $Messages.Add($Message) | Out-Null +} + +if ($ProbeName.Contains("/") -or $ProbeName.Contains("\") -or $ProbeName.StartsWith("workspace-housekeeping-")) { + throw "ProbeName must be a simple disposable filename that does not look like a housekeeping report." +} + +$historyScript = Join-Path $scriptRoot "mapReduceLocalHistory.ps1" +$housekeepingScript = Join-Path $scriptRoot "runWorkspaceHousekeeping.ps1" +$verifierScript = Join-Path $scriptRoot "verifyWorkspaceHousekeeping.ps1" + +$probeRelative = Normalize-RepoPath ".tmp/$ProbeName" +$probeAbsolute = Join-Path $repoRoot $probeRelative +$tmpAbsolute = Join-Path $repoRoot ".tmp" + +New-Item -ItemType Directory -Path $tmpAbsolute -Force | Out-Null +if (Test-Path -LiteralPath $probeAbsolute) { + Remove-Item -LiteralPath $probeAbsolute -Force +} +Set-Content -LiteralPath $probeAbsolute -Encoding utf8 -Value "safe housekeeping self-test probe" + +Invoke-JsonScript $historyScript | Out-Null +$mappedHistory = Read-JsonFile ".tmp/local-history-map-reduce.json" +$mappedSafeProbeEntries = @($mappedHistory.buckets.safe | Where-Object { (Normalize-RepoPath $_.path) -eq $probeRelative }) + +Invoke-JsonScript $housekeepingScript | Out-Null +$cleanupLoop = Read-JsonFile ".tmp/workspace-housekeeping-loop.json" +$removedSafe = @($cleanupLoop.actions.removedSafe | ForEach-Object { Normalize-RepoPath $_ }) + +$failures = [System.Collections.Generic.List[string]]::new() +if ($mappedSafeProbeEntries.Count -ne 1) { + Add-Message $failures "probe was not mapped to exactly one safe entry" +} +if (Test-Path -LiteralPath $probeAbsolute) { + Add-Message $failures "probe still exists after housekeeping" +} +if (-not [bool]$cleanupLoop.actions.safeCleanupApplied) { + Add-Message $failures "housekeeping did not report safe cleanup" +} +if (-not $removedSafe.Contains($probeRelative)) { + Add-Message $failures "removedSafe does not include the probe path" +} +if ([int]$cleanupLoop.finalHistory.safe.entries -ne 0) { + Add-Message $failures "final safe entries should be zero after cleanup" +} +if ([bool]$cleanupLoop.actions.cleanWorktreePruneApplied -or [int]$cleanupLoop.actions.prunedWorktreeCount -ne 0) { + Add-Message $failures "self-test must not prune clean worktrees" +} +if (-not [bool]$cleanupLoop.protectedPathsClean) { + Add-Message $failures "protected paths must remain clean" +} + +Invoke-JsonScript $verifierScript | Out-Null +$finalVerification = Read-JsonFile ".tmp/workspace-housekeeping-verification.json" +if (-not [bool]$finalVerification.passed) { + Add-Message $failures "final verifier did not pass after self-test cleanup" +} +if ([int]$finalVerification.summary.finalSafe -ne 0) { + Add-Message $failures "final verifier reports remaining safe entries" +} +if ([int]$finalVerification.summary.prunedWorktreeCount -ne 0) { + Add-Message $failures "final verifier reports pruned worktrees" +} + +$report = [pscustomobject]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + passed = $failures.Count -eq 0 + failures = @($failures) + probe = [pscustomobject]@{ + path = $probeRelative + mappedSafeEntries = $mappedSafeProbeEntries.Count + removed = -not (Test-Path -LiteralPath $probeAbsolute) + removedSafeMatched = $removedSafe.Contains($probeRelative) + } + cleanup = [pscustomobject]@{ + safeCleanupApplied = $cleanupLoop.actions.safeCleanupApplied + removedSafeCount = $cleanupLoop.actions.removedSafeCount + prunedWorktreeCount = $cleanupLoop.actions.prunedWorktreeCount + finalSafe = $cleanupLoop.finalHistory.safe.entries + protectedPathsClean = $cleanupLoop.protectedPathsClean + } + finalVerification = [pscustomobject]@{ + passed = $finalVerification.passed + operatorStatus = $finalVerification.operatorSummary.status + finalSafe = $finalVerification.summary.finalSafe + finalCaution = $finalVerification.summary.finalCaution + removedSafeCount = $finalVerification.summary.removedSafeCount + prunedWorktreeCount = $finalVerification.summary.prunedWorktreeCount + } +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +$report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $outPath -Encoding utf8 +$report | ConvertTo-Json -Depth 10 + +if ($failures.Count -gt 0) { + throw "Workspace housekeeping self-test failed with $($failures.Count) failure(s)." +} diff --git a/scripts/repo/verifyWorkspaceHousekeeping.ps1 b/scripts/repo/verifyWorkspaceHousekeeping.ps1 new file mode 100644 index 000000000..3728cabdb --- /dev/null +++ b/scripts/repo/verifyWorkspaceHousekeeping.ps1 @@ -0,0 +1,303 @@ +param( + [switch]$SkipRun, + [string]$Report = ".tmp/workspace-housekeeping-loop.json", + [string]$Out = ".tmp/workspace-housekeeping-verification.json", + [int]$MaxSourceReportAgeSeconds = 600, + [int]$MaxFutureReportSkewSeconds = 30 +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path ".").Path +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Read-JsonFile([string]$Path) { + return Get-Content -Raw -LiteralPath (Join-Path $repoRoot $Path) | ConvertFrom-Json +} + +function Add-Message([System.Collections.Generic.List[string]]$Messages, [string]$Message) { + $Messages.Add($Message) | Out-Null +} + +function Normalize-FullPath([string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + return [System.IO.Path]::GetFullPath($Path) +} + +function Test-ReportRepoMatch($ReportObject) { + $reportRepo = Normalize-FullPath $ReportObject.repo + return -not [string]::IsNullOrWhiteSpace($reportRepo) -and $reportRepo.Equals($repoRoot, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Get-ReportAgeSeconds([string]$GeneratedAt, [datetime]$NowUtc) { + if ([string]::IsNullOrWhiteSpace($GeneratedAt)) { + return $null + } + + try { + $timestamp = [datetime]::Parse($GeneratedAt).ToUniversalTime() + return [Math]::Round(($NowUtc - $timestamp).TotalSeconds, 3) + } catch { + return $null + } +} + +function Test-GitIgnored([string]$Path) { + & git check-ignore -q -- $Path + return $LASTEXITCODE -eq 0 +} + +function Invoke-StagedDiffCheck { + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& git diff --cached --check 2>&1) + $exitCode = $LASTEXITCODE + return [pscustomobject]@{ + passed = $exitCode -eq 0 + exitCode = $exitCode + issues = @($output | ForEach-Object { $_.ToString() }) + } + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } +} + +function Get-GitPathList([string[]]$Arguments) { + $items = [System.Collections.Generic.List[string]]::new() + @(& git @Arguments) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { + $items.Add($_.ToString()) | Out-Null + } + return $items +} + +function Test-HousekeepingPath([string]$Path) { + $normalized = $Path -replace "\\", "/" + return ( + $normalized -eq "docs/runbooks/WORKSPACE_HOUSEKEEPING.md" -or + $normalized -eq "package.json" -or + $normalized -like "scripts/repo/*" + ) +} + +if (-not $SkipRun) { + $housekeepingScript = Join-Path $scriptRoot "runWorkspaceHousekeeping.ps1" + & powershell -NoProfile -ExecutionPolicy Bypass -File $housekeepingScript | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Housekeeping run failed before verification." + } +} + +$loop = Read-JsonFile $Report +$history = Read-JsonFile ".tmp/local-history-map-reduce.json" +$augment = Read-JsonFile ".tmp/augment-upload-scope.json" +$footprint = Read-JsonFile ".tmp/workspace-footprint.json" +$stagedDiffCheck = Invoke-StagedDiffCheck +$stagedPaths = Get-GitPathList @("diff", "--cached", "--name-only") +$unstagedPaths = Get-GitPathList @("diff", "--name-only") +$untrackedPaths = Get-GitPathList @("ls-files", "--others", "--exclude-standard") +$nonHousekeepingStagedPaths = @($stagedPaths | Where-Object { -not (Test-HousekeepingPath $_) }) +$nonHousekeepingUnstagedPaths = @($unstagedPaths | Where-Object { -not (Test-HousekeepingPath $_) }) +$nonHousekeepingUntrackedPaths = @($untrackedPaths | Where-Object { -not (Test-HousekeepingPath $_) }) +$driftSummary = [pscustomobject]@{ + stagedCount = $stagedPaths.Count + unstagedCount = $unstagedPaths.Count + untrackedCount = $untrackedPaths.Count + housekeepingOnly = ( + $nonHousekeepingStagedPaths.Count -eq 0 -and + $nonHousekeepingUnstagedPaths.Count -eq 0 -and + $nonHousekeepingUntrackedPaths.Count -eq 0 + ) + stagedPaths = @($stagedPaths) + unstagedPaths = @($unstagedPaths) + untrackedPaths = @($untrackedPaths) + nonHousekeepingStagedPaths = @($nonHousekeepingStagedPaths) + nonHousekeepingUnstagedPaths = @($nonHousekeepingUnstagedPaths) + nonHousekeepingUntrackedPaths = @($nonHousekeepingUntrackedPaths) +} + +$failures = [System.Collections.Generic.List[string]]::new() +$warnings = [System.Collections.Generic.List[string]]::new() +$nowUtc = (Get-Date).ToUniversalTime() + +$sourceReports = [ordered]@{ + loop = [pscustomobject]@{ + path = $Report + repo = $loop.repo + generatedAt = $loop.generatedAt + repoMatches = Test-ReportRepoMatch $loop + ageSeconds = Get-ReportAgeSeconds $loop.generatedAt $nowUtc + fresh = $false + } + history = [pscustomobject]@{ + path = ".tmp/local-history-map-reduce.json" + repo = $history.repo + generatedAt = $history.generatedAt + repoMatches = Test-ReportRepoMatch $history + ageSeconds = Get-ReportAgeSeconds $history.generatedAt $nowUtc + fresh = $false + } + augment = [pscustomobject]@{ + path = ".tmp/augment-upload-scope.json" + repo = $augment.repo + generatedAt = $augment.generatedAt + repoMatches = Test-ReportRepoMatch $augment + ageSeconds = Get-ReportAgeSeconds $augment.generatedAt $nowUtc + fresh = $false + } + footprint = [pscustomobject]@{ + path = ".tmp/workspace-footprint.json" + repo = $footprint.repo + generatedAt = $footprint.generatedAt + repoMatches = Test-ReportRepoMatch $footprint + ageSeconds = Get-ReportAgeSeconds $footprint.generatedAt $nowUtc + fresh = $false + } +} + +foreach ($property in $sourceReports.GetEnumerator()) { + if (-not [bool]$property.Value.repoMatches) { + Add-Message $failures "source report repo mismatch: $($property.Key)" + } + if ([string]::IsNullOrWhiteSpace($property.Value.generatedAt)) { + Add-Message $failures "source report missing generatedAt: $($property.Key)" + } + if ($null -eq $property.Value.ageSeconds) { + Add-Message $failures "source report generatedAt is not parseable: $($property.Key)" + } elseif ([double]$property.Value.ageSeconds -lt (-1 * $MaxFutureReportSkewSeconds)) { + Add-Message $failures "source report timestamp is too far in the future: $($property.Key) ageSeconds=$($property.Value.ageSeconds)" + } elseif ([double]$property.Value.ageSeconds -gt $MaxSourceReportAgeSeconds) { + Add-Message $failures "source report is stale: $($property.Key) ageSeconds=$($property.Value.ageSeconds)" + } else { + $property.Value.fresh = $true + } +} + +if (-not [bool]$stagedDiffCheck.passed) { + Add-Message $failures "git diff --cached --check must pass" +} +if (-not [bool]$loop.augmentScope.passed) { + Add-Message $failures "augmentScope.passed must be true" +} +if (-not [bool]$loop.augmentScope.candidateCountPassed) { + Add-Message $failures "augmentScope.candidateCountPassed must be true" +} +if (-not [bool]$loop.augmentScope.criticalIgnoreProbesPassed) { + Add-Message $failures "augmentScope.criticalIgnoreProbesPassed must be true" +} +if ([int]$loop.augmentScope.criticalIgnoreProbeFailures -ne 0) { + Add-Message $failures "augmentScope.criticalIgnoreProbeFailures must be 0" +} +if ([int]$loop.augmentScope.untrackedIncluded -ne 0) { + Add-Message $failures "augmentScope.untrackedIncluded must be 0" +} +if ([int]$loop.augmentScope.candidateFiles -gt [int]$loop.augmentScope.threshold) { + Add-Message $failures "candidate files exceed Augment threshold" +} +if (-not [bool]$loop.protectedPathsClean) { + Add-Message $failures "protectedPathsClean must be true" +} +foreach ($pathReport in @($loop.protectedPaths)) { + if (-not [bool]$pathReport.exists) { + Add-Message $failures "protected path missing: $($pathReport.path)" + } + if (-not [bool]$pathReport.clean) { + Add-Message $failures "protected path has staged or unstaged drift: $($pathReport.path)" + } +} +if ([int]$loop.finalHistory.safe.entries -ne 0) { + Add-Message $failures "finalHistory.safe.entries must be 0" +} +if ([bool]$loop.actions.cleanWorktreePruneApplied) { + Add-Message $failures "normal verification must not prune clean worktrees" +} +if ([int]$loop.actions.prunedWorktreeCount -ne 0) { + Add-Message $failures "prunedWorktreeCount must be 0 in normal verification" +} + +foreach ($reportPath in @( + ".tmp/workspace-housekeeping-loop.json", + ".tmp/local-history-map-reduce.json", + ".tmp/augment-upload-scope.json", + ".tmp/workspace-footprint.json", + ".tmp/workspace-housekeeping-verification.json", + ".tmp/workspace-housekeeping-self-test.json" +)) { + if (-not (Test-GitIgnored $reportPath)) { + Add-Message $failures "report path is not ignored by Git: $reportPath" + } +} + +if ([int]$loop.finalHistory.caution.entries -gt 0) { + Add-Message $warnings "caution worktrees present: $($loop.finalHistory.caution.entries)" +} +if ([int]$loop.finalStats.invalidRegistered -gt 0) { + Add-Message $warnings "invalid registered worktrees present: $($loop.finalStats.invalidRegistered)" +} +if ([int]$loop.nestedSummary.missing -gt 0) { + Add-Message $warnings "missing nested registered worktrees present: $($loop.nestedSummary.missing)" +} +if ([int]$loop.externalSummary.missing -gt 0) { + Add-Message $warnings "missing external registered worktrees present: $($loop.externalSummary.missing)" +} +if (-not [bool]$driftSummary.housekeepingOnly) { + Add-Message $warnings "non-housekeeping drift is present" +} + +$cautionEntries = @($history.buckets.caution | Select-Object path, reason, branch, dirty, locked, exists, gitUsable) +$operatorStatus = if ($failures.Count -gt 0) { "FAIL" } elseif ($warnings.Count -gt 0) { "WARN" } else { "PASS" } +$operatorMessage = if ($operatorStatus -eq "FAIL") { + "Housekeeping verification failed: $($failures.Count) failure(s), $($warnings.Count) warning(s)." +} elseif ($operatorStatus -eq "WARN") { + "Housekeeping verified with attention items: $($warnings.Count) warning(s)." +} else { + "Housekeeping verified: Augment $($loop.augmentScope.candidateFiles)/$($loop.augmentScope.threshold), safe=$($loop.finalHistory.safe.entries), caution=$($loop.finalHistory.caution.entries), protected paths clean, drift housekeeping-only." +} +$verification = [pscustomobject]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + repo = $repoRoot + passed = $failures.Count -eq 0 + operatorSummary = [pscustomobject]@{ + status = $operatorStatus + notifyRecommended = $operatorStatus -ne "PASS" + message = $operatorMessage + launchRelevantBlockers = @($failures) + attentionItems = @($warnings) + } + failures = @($failures) + warnings = @($warnings) + summary = [pscustomobject]@{ + candidateFiles = $loop.augmentScope.candidateFiles + threshold = $loop.augmentScope.threshold + criticalIgnoreProbesPassed = $loop.augmentScope.criticalIgnoreProbesPassed + untrackedIncluded = $loop.augmentScope.untrackedIncluded + finalSafe = $loop.finalHistory.safe.entries + finalCaution = $loop.finalHistory.caution.entries + finalKeep = $loop.finalHistory.keep.entries + protectedPathsClean = $loop.protectedPathsClean + removedSafeCount = $loop.actions.removedSafeCount + prunedWorktreeCount = $loop.actions.prunedWorktreeCount + invalidRegistered = $loop.finalStats.invalidRegistered + stagedDiffCheckPassed = $stagedDiffCheck.passed + housekeepingOnlyDrift = $driftSummary.housekeepingOnly + sourceReportsMatch = -not @($sourceReports.GetEnumerator() | Where-Object { -not [bool]$_.Value.repoMatches }).Count + sourceReportsFresh = -not @($sourceReports.GetEnumerator() | Where-Object { -not [bool]$_.Value.fresh }).Count + maxSourceReportAgeSeconds = $MaxSourceReportAgeSeconds + maxFutureReportSkewSeconds = $MaxFutureReportSkewSeconds + } + cautionEntries = $cautionEntries + stagedDiffCheck = $stagedDiffCheck + drift = $driftSummary + sourceReports = [pscustomobject]$sourceReports + augmentReportPassed = $augment.passed +} + +$outPath = Join-Path $repoRoot $Out +New-Item -ItemType Directory -Path (Split-Path -Parent $outPath) -Force | Out-Null +$verification | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $outPath -Encoding utf8 +$verification | ConvertTo-Json -Depth 10 + +if ($failures.Count -gt 0) { + throw "Workspace housekeeping verification failed with $($failures.Count) failure(s)." +} diff --git a/scripts/scratchnode/runLaunchGoalLoop.mjs b/scripts/scratchnode/runLaunchGoalLoop.mjs new file mode 100644 index 000000000..4d83fb1f6 --- /dev/null +++ b/scripts/scratchnode/runLaunchGoalLoop.mjs @@ -0,0 +1,484 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +const repoRoot = process.cwd(); +const args = new Set(process.argv.slice(2)); +const shouldPrintJson = args.has("--json"); +const outPath = resolve(repoRoot, ".tmp/scratchnode-launch-goal-loop.json"); + +const reportPaths = { + housekeeping: ".tmp/workspace-housekeeping-verification.json", + launch: ".tmp/scratchnode-launch-scan.json", + augment: ".tmp/augment-upload-scope.json", + housekeepingLoop: ".tmp/workspace-housekeeping-loop.json", + localHistory: ".tmp/local-history-map-reduce.json", + goalLoop: ".tmp/scratchnode-launch-goal-loop.json", +}; + +function tail(text, maxLength = 16_000) { + if (text.length <= maxLength) return text; + return `...[truncated ${text.length - maxLength} chars]\n${text.slice(-maxLength)}`; +} + +function readJson(relativePath) { + const absolutePath = resolve(repoRoot, relativePath); + if (!existsSync(absolutePath)) return null; + try { + return JSON.parse(readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, "")); + } catch (error) { + return { + parseError: error instanceof Error ? error.message : String(error), + }; + } +} + +function run(command, commandArgs, options = {}) { + const started = performance.now(); + return new Promise((resolveRun) => { + const child = spawn(command, commandArgs, { + cwd: repoRoot, + env: process.env, + shell: process.platform === "win32", + windowsHide: true, + ...options, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + resolveRun({ + command: [command, ...commandArgs].join(" "), + exitCode: 1, + durationMs: Math.round(performance.now() - started), + stdout: tail(stdout), + stderr: tail(`${stderr}\n${error.message}`.trim()), + }); + }); + child.on("close", (exitCode) => { + resolveRun({ + command: [command, ...commandArgs].join(" "), + exitCode: exitCode ?? 1, + durationMs: Math.round(performance.now() - started), + stdout: tail(stdout), + stderr: tail(stderr), + }); + }); + }); +} + +function unique(items) { + return [...new Set(items.filter(Boolean))]; +} + +function knownCautionEntries(housekeepingReport) { + const entries = (housekeepingReport?.cautionEntries ?? []).filter((entry) => + /clean registered worktree; explicit prune only/i.test(entry.reason ?? ""), + ); + const invalidRegistered = Number(housekeepingReport?.summary?.invalidRegistered ?? 0); + if (invalidRegistered > 0) { + entries.push({ + path: "git worktree metadata", + reason: `invalid registered worktrees present: ${invalidRegistered}; keep-classified by local-history map/reduce`, + }); + } + return entries; +} + +function actionableAttentionItems(housekeepingReport) { + const attentionItems = housekeepingReport?.operatorSummary?.attentionItems ?? []; + const knownCleanWorktreeCautionCount = (housekeepingReport?.cautionEntries ?? []).filter((entry) => + /clean registered worktree; explicit prune only/i.test(entry.reason ?? ""), + ).length; + const invalidRegistered = Number(housekeepingReport?.summary?.invalidRegistered ?? 0); + return attentionItems.filter((item) => { + const cautionMatch = item.match(/^caution worktrees present: (\d+)$/i); + if (cautionMatch) return Number(cautionMatch[1]) !== knownCleanWorktreeCautionCount; + const invalidMatch = item.match(/^invalid registered worktrees present: (\d+)$/i); + if (invalidMatch) return Number(invalidMatch[1]) !== invalidRegistered; + return true; + }); +} + +function buildCriterion(name, ok, detail) { + return { + name, + ok: !!ok, + detail: detail ?? "", + }; +} + +function formatLaunchSummaryDetail(launchReport) { + const summary = launchReport?.summary; + if (!summary) return "missing launch report"; + const detail = [ + `blockers=${summary.blockers}`, + `warnings=${summary.warnings}`, + `liveFailures=${summary.liveFailures}`, + `interactiveFailures=${summary.interactiveFailures}`, + ]; + if (summary.remoteProbeInfra?.networkAccessDenied) { + detail.push( + `rawLiveFailures=${summary.rawLiveFailures}`, + `rawInteractiveFailures=${summary.rawInteractiveFailures}`, + `remoteProbeInfra=${summary.remoteProbeInfra.reason}`, + ); + } + return detail.join(", "); +} + +function parseFrontmatter(text) { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + if (!match) return {}; + const data = {}; + for (const line of match[1].split(/\r?\n/)) { + const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!field) continue; + data[field[1]] = field[2].trim(); + } + return data; +} + +function walkMarkdownFiles(relativeDir) { + const root = resolve(repoRoot, relativeDir); + if (!existsSync(root)) return []; + const files = []; + const walk = (absoluteDir) => { + for (const item of readdirSync(absoluteDir)) { + const absolutePath = resolve(absoluteDir, item); + const stat = statSync(absolutePath); + if (stat.isDirectory()) { + walk(absolutePath); + } else if (/\.md$/i.test(item)) { + files.push(absolutePath); + } + } + }; + walk(root); + return files.sort(); +} + +function readGoalQueue() { + return walkMarkdownFiles("goals") + .map((absolutePath) => { + const text = readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, ""); + const frontmatter = parseFrontmatter(text); + if (!frontmatter.id || !frontmatter.status || !frontmatter.mode) return null; + const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? ""; + const goalSentence = text.match(/^#\s+Goal\s*\r?\n+\s*([^\r\n]+)/im)?.[1]?.trim() ?? ""; + const title = frontmatter.title ?? (heading && heading !== "Goal" ? heading : goalSentence) ?? frontmatter.id ?? absolutePath; + const relativePath = absolutePath.slice(repoRoot.length + 1).replace(/\\/g, "/"); + return { + id: frontmatter.id ?? relativePath.replace(/\.md$/i, "").replace(/\//g, "-"), + title, + surface: frontmatter.surface ?? "unknown", + priority: frontmatter.priority ?? "P3", + status: frontmatter.status ?? "queued", + mode: frontmatter.mode ?? "safe-local-development", + path: relativePath, + }; + }) + .filter(Boolean) + .filter((goal) => goal.status !== "done"); +} + +function priorityRank(priority) { + return { P0: 0, P1: 1, P2: 2, P3: 3 }[priority] ?? 4; +} + +function goalCardsToBacklog(goalCards) { + return goalCards + .filter((goal) => (goal.status === "queued" || goal.status === "active") && goal.mode === "safe-local-development") + .sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.path.localeCompare(b.path)) + .map((goal) => ({ + id: `goal-${goal.id}`, + surface: goal.surface, + area: "goal queue", + priority: goal.priority, + mode: goal.mode, + title: goal.title, + why: `Queued goal card: ${goal.path}`, + maxSlice: "Take one narrow, locally verifiable slice from this goal card; do not expand scope.", + suggestedVerification: + goal.surface === "NodeBench Runtime" + ? ["npm run scratchnode:launch:goal", "npx vitest run convex/__tests__/scratchnode.events.test.ts"] + : ["npm run scratchnode:launch:goal"], + sourcePath: goal.path, + })); +} + +function buildDevelopmentBacklog({ housekeepingReport, launchReport, gitStatus, actionableAttention, launchRelevantBlockers, goalCards }) { + const backlog = []; + + for (const blocker of launchRelevantBlockers) { + backlog.push({ + id: `blocker-${backlog.length + 1}`, + surface: "repo", + area: "release blocker", + priority: "P0", + mode: "fix-first", + title: blocker, + why: "Launch-relevant blockers outrank new product work.", + maxSlice: "Root-cause and fix only this blocker, then rerun the goal loop.", + suggestedVerification: ["npm run scratchnode:launch:goal", "git diff --check"], + }); + } + + for (const item of actionableAttention) { + backlog.push({ + id: `attention-${backlog.length + 1}`, + surface: "repo", + area: "housekeeping", + priority: "P1", + mode: "fix-first", + title: item, + why: "Actionable workspace drift makes future autonomous development less reliable.", + maxSlice: "Fix the smallest housekeeping cause without pruning caution worktrees.", + suggestedVerification: ["npm run repo:housekeeping:check", "npm run scratchnode:launch:goal"], + }); + } + + if (gitStatus) { + backlog.push({ + id: `drift-${backlog.length + 1}`, + surface: "repo", + area: "git drift", + priority: "P1", + mode: "human-gated", + title: "Existing git drift must be classified before new autonomous development", + why: "The loop cannot safely improve product code while unclassified user or agent changes are present.", + maxSlice: "Inspect drift, preserve user changes, and either commit verified agent work or report non-agent drift.", + suggestedVerification: ["git status --short", "git diff --check"], + }); + } + + if (backlog.length > 0) return backlog; + + const queuedGoalBacklog = goalCardsToBacklog(goalCards); + if (queuedGoalBacklog.length > 0) return queuedGoalBacklog; + + const launchChecks = launchReport?.staticChecks ?? []; + const hasGoalAutomationChecks = launchChecks.some((check) => check.plane === "goal-automation"); + const hasNodeBenchLiveChecks = (launchReport?.liveChecks ?? []).some((check) => /nodebench/i.test(check.name)); + const hasNodeBenchInteractiveChecks = (launchReport?.interactiveChecks ?? []).some((check) => /nodebench/i.test(check.name)); + + return [ + { + id: "dev-scratchnode-flow-depth", + surface: "scratchnode.live", + area: "product workflow", + priority: "P1", + mode: "safe-local-development", + title: "Deepen the safe ScratchNode workflow probe", + why: "The current probe opens core modals and toggles private mode; the next quality lift is proving more of the Join -> Chat -> /ask -> private note -> FAQ -> Wiki loop without mutating production.", + maxSlice: "Add one read-only/browser-safe assertion or one local fixture-backed Playwright scenario.", + suggestedVerification: [ + "npm run scratchnode:launch:goal", + "npx playwright test tests/e2e/scratchnode-demo-route-gate.spec.ts tests/e2e/scratchnode-live-route-honesty.spec.ts --project=chromium --workers=1 --reporter=list", + ], + }, + { + id: "dev-nodebench-handoff-depth", + surface: "nodebenchai.com", + area: "ScratchNode handoff", + priority: hasNodeBenchLiveChecks && hasNodeBenchInteractiveChecks ? "P2" : "P1", + mode: "safe-local-development", + title: "Strengthen NodeBench handoff verification", + why: "NodeBench is the private workspace direction; the public launch loop should keep proving that ScratchNode handoff CTAs and /scratchnode-events remain coherent.", + maxSlice: "Add one route assertion, copy/link invariant, or docs-backed detector for NodeBench handoff behavior.", + suggestedVerification: ["npm run scratchnode:launch:goal"], + }, + { + id: "dev-privacy-eval-depth", + surface: "convex/events.ts", + area: "privacy and agent reliability", + priority: "P1", + mode: "safe-local-development", + title: "Add or strengthen a privacy-boundary regression test", + why: "The public/private boundary is ScratchNode's highest-trust invariant and should keep gaining executable coverage.", + maxSlice: "Add one targeted test around /ask excluding private notes, parent /ask trace visibility, or normal-chat not invoking the agent.", + suggestedVerification: ["npx vitest run convex/__tests__/scratchnode.events.test.ts", "npm run scratchnode:launch:goal"], + }, + { + id: "dev-public-repo-polish", + surface: "public repo", + area: "launch positioning", + priority: "P2", + mode: "safe-local-development", + title: "Improve public repo clarity or export safety", + why: "The public repo should stay positioned as high-fidelity prototype plus serious architecture, not an unstructured monorepo dump.", + maxSlice: "Improve one README/runbook/export-script invariant or one public asset/check.", + suggestedVerification: ["npm run scratchnode:launch:scan", "npm run scratchnode:launch:goal"], + }, + { + id: "dev-performance-a11y-polish", + surface: "ScratchNode and NodeBench", + area: "performance/accessibility", + priority: "P2", + mode: "safe-local-development", + title: "Tighten one performance, mobile, or accessibility detector", + why: "Small detector gains compound across the continuous loop and prevent cosmetic regressions from silently shipping.", + maxSlice: "Add one static or browser assertion; avoid speculative visual redesign without screenshot evidence.", + suggestedVerification: ["npm run scratchnode:launch:interactive"], + }, + { + id: "dev-goal-loop-instrumentation", + surface: "automation", + area: "self-improvement loop", + priority: hasGoalAutomationChecks ? "P3" : "P1", + mode: "safe-local-development", + title: "Improve loop instrumentation and evidence quality", + why: "The loop should become easier to judge over time: clearer reports, better candidate ranking, and less noisy notifications.", + maxSlice: "Add one report field, detector, or runbook invariant that makes future autonomous work safer.", + suggestedVerification: ["npm run scratchnode:launch:scan", "npm run scratchnode:launch:goal"], + }, + ]; +} + +async function main() { + const commands = []; + commands.push(await run("npm", ["run", "repo:housekeeping:check"])); + commands.push(await run("npm", ["run", "scratchnode:launch:interactive"])); + commands.push(await run("git", ["status", "--short"])); + commands.push( + await run("git", [ + "check-ignore", + "-v", + reportPaths.housekeeping, + reportPaths.launch, + reportPaths.augment, + reportPaths.housekeepingLoop, + reportPaths.localHistory, + reportPaths.goalLoop, + ]), + ); + + const housekeepingReport = readJson(reportPaths.housekeeping); + const launchReport = readJson(reportPaths.launch); + const gitStatus = commands.find((command) => command.command === "git status --short")?.stdout.trim() ?? ""; + const ignoreCheck = commands.find((command) => command.command.startsWith("git check-ignore")); + const actionableAttention = actionableAttentionItems(housekeepingReport); + const launchRelevantBlockers = housekeepingReport?.operatorSummary?.launchRelevantBlockers ?? []; + const knownCautions = knownCautionEntries(housekeepingReport); + const goalQueue = readGoalQueue(); + const developmentBacklog = buildDevelopmentBacklog({ + housekeepingReport, + launchReport, + gitStatus, + actionableAttention, + launchRelevantBlockers, + goalCards: goalQueue, + }); + + const criteria = [ + buildCriterion( + "housekeeping command passes", + commands[0]?.exitCode === 0 && housekeepingReport?.passed === true, + housekeepingReport?.operatorSummary?.message, + ), + buildCriterion( + "ScratchNode static/live/interactive launch scan passes", + commands[1]?.exitCode === 0 && launchReport?.summary?.passed === true, + formatLaunchSummaryDetail(launchReport), + ), + buildCriterion( + "Augment upload scope stays under threshold", + housekeepingReport?.summary?.candidateFiles < housekeepingReport?.summary?.threshold, + `${housekeepingReport?.summary?.candidateFiles ?? "?"}/${housekeepingReport?.summary?.threshold ?? "?"}`, + ), + buildCriterion("safe local-history cleanup queue is empty", housekeepingReport?.summary?.finalSafe === 0), + buildCriterion("protected product/runtime paths are clean", housekeepingReport?.summary?.protectedPathsClean === true), + buildCriterion("source reports match repo and are fresh", housekeepingReport?.summary?.sourceReportsMatch === true && housekeepingReport?.summary?.sourceReportsFresh === true), + buildCriterion("git drift is clean after the loop", gitStatus.length === 0, gitStatus), + buildCriterion(".tmp loop reports are ignored", ignoreCheck?.exitCode === 0, ignoreCheck?.stdout.trim()), + buildCriterion("no launch-relevant blockers remain", launchRelevantBlockers.length === 0, launchRelevantBlockers.join("; ")), + buildCriterion("no actionable attention items remain", actionableAttention.length === 0, actionableAttention.join("; ")), + ]; + + const passed = criteria.every((criterion) => criterion.ok); + const report = { + generatedAt: new Date().toISOString(), + repo: repoRoot, + goal: { + id: "scratchnode-nodebench-development-goal-cron", + objective: "Keep ScratchNode and NodeBench continuously improving in small verified slices while preserving production safety.", + stopCondition: + "The loop is clean when housekeeping, Augment scope, ScratchNode static/live/interactive checks, NodeBench handoff checks, tmp-ignore probes, and git drift all pass with no launch-relevant blockers; a development slice is done only when it is locally verified and either committed or explicitly reported.", + sourceRefs: [ + "docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md", + "docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md", + "docs/runbooks/WORKSPACE_HOUSEKEEPING.md", + ], + successCriteria: criteria, + }, + workflowModel: { + issueQueue: + "Batch findings into blockers, attention items, known-safe cautions, and one focused development candidate per loop.", + specialistPasses: [ + "housekeeping", + "ScratchNode product workflow", + "NodeBench handoff and workspace direction", + "privacy and agent reliability", + "performance/accessibility", + "public repo positioning", + ], + developmentCadence: + "If gates are red, fix the smallest blocker first. If gates are green, pick one safe-local-development backlog item, make a narrow improvement, verify it, and commit or report the residual risk.", + repeatedFailureRule: "After three repeated failures on the same gate, change strategy by instrumenting, isolating, rolling back the risky slice, or reducing scope.", + safetyBoundary: + "The loop may edit local source, tests, scripts, and docs, but is read-only against production: it navigates, opens modals, copies safe controls, and inspects reports without sending chat, creating events, publishing wikis, deploying, pushing, or mutating live user data.", + }, + summary: { + passed, + notifyRecommended: !passed, + failures: criteria.filter((criterion) => !criterion.ok).map((criterion) => criterion.name), + knownCautionCount: knownCautions.length, + actionableAttentionCount: actionableAttention.length, + launchRelevantBlockerCount: launchRelevantBlockers.length, + queuedGoalCount: goalQueue.filter((goal) => goal.status === "queued").length, + gitDriftClean: gitStatus.length === 0, + nextDevelopmentCandidate: developmentBacklog[0]?.id ?? null, + }, + commands, + reports: { + housekeeping: housekeepingReport, + launch: launchReport, + }, + knownCautionEntries: knownCautions, + actionableAttentionItems: actionableAttention, + launchRelevantBlockers, + goalQueue, + developmentBacklog, + gitStatus, + }; + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`); + + if (shouldPrintJson) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log( + `ScratchNode launch goal loop: ${passed ? "PASS" : "FAIL"} ` + + `(failures=${report.summary.failures.length}, knownCautions=${knownCautions.length})`, + ); + console.log(`Report: ${outPath}`); + for (const failure of report.summary.failures) { + console.log(`- ${failure}`); + } + } + + if (!passed) process.exitCode = 1; +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/scratchnode/scanLaunch.mjs b/scripts/scratchnode/scanLaunch.mjs new file mode 100644 index 000000000..716fa0041 --- /dev/null +++ b/scripts/scratchnode/scanLaunch.mjs @@ -0,0 +1,2266 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +const repoRoot = process.cwd(); +const args = new Set(process.argv.slice(2)); +const shouldRunLive = args.has("--live") || args.has("--interactive"); +const shouldRunInteractive = args.has("--interactive"); +const shouldPrintJson = args.has("--json"); +const shouldFailOnWarn = args.has("--fail-on-warn"); +const outPath = resolve(repoRoot, ".tmp/scratchnode-launch-scan.json"); + +const files = { + homeV5: "public/proto/home-v5.html", + docsHtml: "public/proto/docs.html", + vercel: "vercel.json", + scratchnodeConfig: "api/scratchnode-config.js", + scratchnodeWikiServerless: "api/scratchnode-wiki.js", + events: "convex/events.ts", + notes: "convex/notes.ts", + users: "convex/users.ts", + exportScript: "scripts/repo/export-scratchnode-live-public.mjs", + goalLoopScript: "scripts/scratchnode/runLaunchGoalLoop.mjs", + splitRunbook: "docs/runbooks/PUBLIC_SCRATCHNODE_LIVE_SPLIT.md", + launchRunbook: "docs/runbooks/SCRATCHNODE_LAUNCH_DAY.md", + goalRunbook: "docs/runbooks/GOAL_MODE_RELEASE_AUTOPILOT.md", + goalQueue: "goals/README.md", + scratchnodeGoal: "goals/scratchnode/001-first-time-user-clarity.md", + scratchnodeEventLogGoal: "goals/scratchnode/004-event-log-followups.md", + nodebenchGoal: "goals/nodebench/001-event-handoff.md", + runtimeGoal: "goals/runtime/001-public-private-boundary.md", + eventsRuntimeBoundarySpec: "convex/events.runtime-boundary.test.ts", + routeHonestySpec: "tests/e2e/scratchnode-live-route-honesty.spec.ts", + scratchnodePublicWikiSpec: "tests/e2e/scratchnode-public-wiki.spec.ts", + scratchnodeWikiBridgeSpec: "src/features/events/views/ScratchnodeWikiBridge.test.tsx", + demoQa: "qa/run_demo_full.md", + readme: "README.md", + license: "LICENSE", + security: "SECURITY.md", + contributing: "CONTRIBUTING.md", +}; + +const staticChecks = []; +const findings = []; +const liveChecks = []; +const interactiveChecks = []; + +function readText(relativePath) { + const absolutePath = resolve(repoRoot, relativePath); + if (!existsSync(absolutePath)) return ""; + return readFileSync(absolutePath, "utf8"); +} + +function lineFor(text, index) { + return text.slice(0, Math.max(0, index)).split(/\r?\n/).length; +} + +function maskPattern(text, pattern) { + return text.replace(pattern, (match) => + match + .split("") + .map((char) => (char === "\n" || char === "\r" ? char : " ")) + .join(""), + ); +} + +function maskComments(text) { + return maskPattern(maskPattern(text, //g), /\/\*[\s\S]*?\*\//g); +} + +function addCheck(check) { + staticChecks.push({ + ok: !!check.ok, + name: check.name, + plane: check.plane ?? "static", + detail: check.detail ?? "", + optional: !!check.optional, + }); +} + +function addFinding(finding) { + findings.push({ + id: `SN-${String(findings.length + 1).padStart(3, "0")}`, + severity: finding.severity ?? "warn", + safety: finding.safety ?? "human-gated", + plane: finding.plane ?? "static", + title: finding.title, + path: finding.path, + line: finding.line ?? null, + detail: finding.detail ?? "", + recommendation: finding.recommendation ?? "", + }); +} + +function addLiveCheck(check) { + liveChecks.push({ + ok: !!check.ok, + name: check.name, + url: check.url, + status: check.status ?? null, + durationMs: Math.round(check.durationMs ?? 0), + detail: check.detail ?? "", + optional: !!check.optional, + }); +} + +function addInteractiveCheck(check) { + interactiveChecks.push({ + ok: !!check.ok, + name: check.name, + url: check.url, + durationMs: Math.round(check.durationMs ?? 0), + detail: check.detail ?? "", + optional: !!check.optional, + }); +} + +function checkRequiredFile(relativePath, name = relativePath) { + const ok = existsSync(resolve(repoRoot, relativePath)); + addCheck({ ok, name: `required file: ${name}`, detail: relativePath }); + if (!ok) { + addFinding({ + severity: "blocker", + safety: "manual", + title: `Missing required launch file: ${name}`, + path: relativePath, + recommendation: "Restore or regenerate this file before public launch.", + }); + } +} + +function attrValue(tag, attr) { + const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i")); + return match?.[2] ?? match?.[3] ?? match?.[4] ?? null; +} + +function hasAttr(tag, attr) { + return new RegExp(`\\b${attr}\\s*=`, "i").test(tag); +} + +function isInsideForm(text, index) { + const before = text.slice(0, index); + return before.lastIndexOf(" before.lastIndexOf("]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? ""; + addCheck({ + ok: /ScratchNode/i.test(title), + name: "home-v5 title is ScratchNode branded", + detail: title, + }); + + const canonical = html.match(/]*rel=["']canonical["'][^>]*>/i)?.[0] ?? ""; + addCheck({ + ok: /https:\/\/scratchnode\.live\//i.test(canonical), + name: "home-v5 canonical points at scratchnode.live", + detail: canonical.slice(0, 180), + }); + + const firstTimeFlow = html.match(/]*data-first-time-flow[^>]*>[\s\S]*?<\/nav>/i)?.[0] ?? ""; + const hasFirstTimeClarityRail = + /aria-label=["']First-time attendee flow["']/i.test(firstTimeFlow) && + /data-flow-step=["']join["']/i.test(firstTimeFlow) && + /data-flow-step=["']chat["']/i.test(firstTimeFlow) && + /data-flow-step=["']ask["']/i.test(firstTimeFlow) && + /data-flow-step=["']private-note["']/i.test(firstTimeFlow) && + /data-flow-step=["']wiki["']/i.test(firstTimeFlow); + addCheck({ + ok: hasFirstTimeClarityRail, + name: "home-v5 has first-time attendee flow rail", + plane: "product-clarity", + detail: "join -> chat -> /ask -> private note -> wiki", + }); + if (!hasFirstTimeClarityRail) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "product-clarity", + title: "First-time attendee flow rail is missing or incomplete", + path, + recommendation: + "Expose Join, Chat, /ask, Private note, and Wiki as one scannable first-viewport flow in home-v5.", + }); + } + + const h1Matches = [...masked.matchAll(/ 1) { + addFinding({ + severity: "warn", + safety: "human-gated", + title: "Multiple static H1 elements in home-v5", + path, + line: lineFor(html, h1Matches[1].index ?? 0), + detail: `Detected ${h1Matches.length} static H1 tags.`, + recommendation: "Keep one page-level H1 and downgrade secondary headings after visual review.", + }); + } + + for (const match of masked.matchAll(/]*>/gi)) { + const tag = match[0]; + if (hasAttr(tag, "type")) continue; + const line = lineFor(html, match.index ?? 0); + const inForm = isInsideForm(masked, match.index ?? 0); + addFinding({ + severity: "warn", + safety: inForm ? "human-gated" : "auto", + title: inForm ? "Button inside form missing explicit type" : "Button outside form missing type=\"button\"", + path, + line, + detail: tag.slice(0, 180), + recommendation: inForm + ? "Review whether the button should submit or be type=\"button\"." + : "Add type=\"button\"; outside forms this is behavior-preserving.", + }); + } + + for (const match of masked.matchAll(/]*target\s*=\s*["']?_blank["']?[^>]*>/gi)) { + const tag = match[0]; + const rel = attrValue(tag, "rel") ?? ""; + if (/\bnoopener\b/i.test(rel)) continue; + addFinding({ + severity: "warn", + safety: "auto", + title: "target=_blank link missing rel=noopener", + path, + line: lineFor(html, match.index ?? 0), + detail: tag.slice(0, 180), + recommendation: "Add rel=\"noopener noreferrer\".", + }); + } + + for (const match of masked.matchAll(/]*>/gi)) { + const tag = match[0]; + const line = lineFor(html, match.index ?? 0); + if (!hasAttr(tag, "alt")) { + addFinding({ + severity: "warn", + safety: "auto", + title: "Image missing alt attribute", + path, + line, + detail: tag.slice(0, 180), + recommendation: "Add descriptive alt text or alt=\"\" for decorative images.", + }); + } + if (line > 2300 && !hasAttr(tag, "loading")) { + addFinding({ + severity: "warn", + safety: "auto", + title: "Likely below-fold image missing lazy loading", + path, + line, + detail: tag.slice(0, 180), + recommendation: "Add loading=\"lazy\" after confirming it is not first-viewport media.", + }); + } + } + + const cssBlocks = [...html.matchAll(/[^{}]+{[^{}]*backdrop-filter\s*:[^{}]*}/gi)]; + for (const match of cssBlocks) { + const block = match[0]; + if (/-webkit-backdrop-filter\s*:/i.test(block)) continue; + addFinding({ + severity: "warn", + safety: "auto", + title: "backdrop-filter rule missing Safari prefix", + path, + line: lineFor(html, match.index ?? 0), + detail: block.slice(0, 220).replace(/\s+/g, " "), + recommendation: "Mirror the rule with -webkit-backdrop-filter for iOS Safari.", + }); + } + + for (const match of html.matchAll(/\bsetInterval\s*\(/g)) { + const start = Math.max(0, (match.index ?? 0) - 500); + const end = Math.min(html.length, (match.index ?? 0) + 900); + const windowText = html.slice(start, end); + const hasHiddenGuard = /document\.hidden|visibilitychange|clearInterval/i.test(windowText); + if (!hasHiddenGuard) { + addFinding({ + severity: "warn", + safety: "human-gated", + title: "Polling interval lacks nearby visibility/cleanup guard", + path, + line: lineFor(html, match.index ?? 0), + detail: "setInterval without nearby document.hidden, visibilitychange, or clearInterval signal.", + recommendation: "Gate polling while document.hidden and clear intervals on teardown where practical.", + }); + } + } + + const listenerCount = [...html.matchAll(/\baddEventListener\s*\(/g)].length; + const removeListenerCount = [...html.matchAll(/\bremoveEventListener\s*\(/g)].length; + addCheck({ + ok: listenerCount <= 30 || removeListenerCount > 0, + name: "interactive listener cleanup signal", + detail: `addEventListener=${listenerCount}, removeEventListener=${removeListenerCount}`, + optional: true, + }); + if (listenerCount > 30 && removeListenerCount === 0) { + addFinding({ + severity: "warn", + safety: "human-gated", + title: "Many event listeners with no removeEventListener signal", + path, + detail: `Detected ${listenerCount} addEventListener calls and no removeEventListener calls.`, + recommendation: "Audit lifecycle for route changes, overlays, and repeated event joins.", + }); + } + + for (const match of html.matchAll(/\b(?:href|action)\s*=\s*["']\/scratchnode-events(?:[?#][^"']*)?["']/gi)) { + addFinding({ + severity: "blocker", + safety: "manual", + title: "Relative /scratchnode-events link in ScratchNode shell", + path, + line: lineFor(html, match.index ?? 0), + detail: match[0], + recommendation: "Use absolute https://nodebenchai.com/scratchnode-events links from scratchnode.live.", + }); + } + + addCheck({ + ok: /PUBLIC_BASE_URL/.test(html) && /WORKSPACE_BASE_URL/.test(html), + name: "home-v5 exposes separate ScratchNode and NodeBench base URLs", + detail: "PUBLIC_BASE_URL and WORKSPACE_BASE_URL present", + }); + + const hasPrivateHandoffContract = + /function\s+buildNodeBenchEventPrivateUrl\s*\(/.test(html) && + /WORKSPACE_BASE_URL[\s\S]{0,360}['"]\/scratchnode-events\?source=scratchnode['"]/.test(html) && + /event=['"]?\s*\+\s*encodeURIComponent\(EVENT_SLUG\)/.test(html) && + /continuation=['"]?\s*\+\s*encodeURIComponent\(['"]private-notes['"]\)/.test(html) && + /publicArtifact=['"]?\s*\+\s*encodeURIComponent\(['"]event-wiki['"]\)/.test(html) && + /function\s+buildNodeBenchTokenizedPrivateUrl\s*\(\s*token\s*\)/.test(html) && + /WORKSPACE_BASE_URL[\s\S]{0,220}['"]\/events\/['"][\s\S]{0,220}['"]\/private['"]/.test(html) && + /\?token=['"]?\s*\+\s*encodeURIComponent\(token\)/.test(html) && + /scratchnodeHandoff:mintEventHandoffToken/.test(html) && + /function\s+openNodeBenchPrivateHandoff\s*\(/.test(html) && + /deeper research, reports, and follow-ups across people, companies, topics, and anchors/i.test(html); + addCheck({ + ok: hasPrivateHandoffContract, + name: "ScratchNode private handoff targets NodeBench event artifact", + plane: "nodebench-handoff", + detail: "tokenized /events/:slug/private success path plus /scratchnode-events honest fallback and deeper follow-up copy", + }); + if (!hasPrivateHandoffContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "nodebench-handoff", + title: "NodeBench private handoff URL contract is missing or incomplete", + path, + recommendation: + "Ensure ScratchNode mints a handoff token before navigating to /events/:eventSlug/private and falls back to /scratchnode-events with event context when minting is unavailable.", + }); + } + + const hasPrivateAnchorContract = + /client\.onUpdate\(['"]notes:listMyAnchors['"][\s\S]{0,220}\{\s*ownerKey:\s*noteOwnerKey,\s*eventId\s*\}/i.test(html) && + /window\._sn_anchors_by_target\s*=\s*new Map\(\)/i.test(html) && + /window\._sn_anchors_by_note\s*=\s*new Map\(\)/i.test(html) && + /className\s*=\s*['"]sn-anchor-pin['"]/i.test(html) && + /data-anchor-id/i.test(html) && + /data-note-id/i.test(html) && + /window\._sn_pending_anchor/i.test(html) && + /client\.mutation\(['"]notes:createNoteAnchor['"]/i.test(html) && + /There is NO public broadcast of anchor data/i.test(html); + addCheck({ + ok: hasPrivateAnchorContract, + name: "home-v5 private note anchors are owner-scoped and preservable", + plane: "privacy", + detail: "listMyAnchors ownerKey subscription, sn-anchor-pin ids, pending-anchor create path", + }); + if (!hasPrivateAnchorContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "privacy", + title: "Private note anchor contract is missing or incomplete", + path, + recommendation: + "Ensure private note anchors render from owner-keyed listMyAnchors, expose note/anchor ids for verification, and create anchors through notes:createNoteAnchor only.", + }); + } + + const uncommentedHtml = maskComments(html); + const usesBrowserGeolocation = + /\bnavigator\.geolocation\b/i.test(uncommentedHtml) || + /\b(?:getCurrentPosition|watchPosition)\s*\(/i.test(uncommentedHtml); + const hasManualLocationSpotContract = + /var\s+MANUAL_LOCATION_SPOTS\s*=\s*\[/i.test(html) && + /Booth 12/i.test(html) && + /Lobby/i.test(html) && + /Panel Room A/i.test(html) && + /Investor Lounge/i.test(html) && + /Afterparty/i.test(html) && + /function\s+detectManualLocationSpot\s*\(/i.test(html) && + /function\s+renderManualLocationSpot\s*\(/i.test(html) && + /data-location-spot/i.test(html) && + /renderManualLocationSpot\(row,\s*intent\.clean\)/i.test(html) && + /renderManualLocationSpot\(row,\s*msg\.text\)/i.test(html); + addCheck({ + ok: hasManualLocationSpotContract && !usesBrowserGeolocation, + name: "manual location spots are typed event-log chips, not GPS", + plane: "event-log", + detail: usesBrowserGeolocation + ? "browser geolocation API detected" + : "Booth/Lobby/Panel/Investor/Afterparty typed spot fixtures, no geolocation", + }); + if (!hasManualLocationSpotContract || usesBrowserGeolocation) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "event-log", + title: "Manual location spot event-log contract is missing or unsafe", + path, + recommendation: + "Render only explicitly typed venue spots as public event-log chips and keep GPS/geolocation APIs out of ScratchNode Live.", + }); + } + + const hasPeopleCompanyTagContract = + /var\s+ROOM_MEMBERS\s*=\s*\[/i.test(html) && + /function\s+renderMentions\s*\(/i.test(html) && + /function\s+renderEventLogTags\s*\(/i.test(html) && + /data-member/i.test(html) && + /data-event-log-tag/i.test(html) && + /renderEventLogTags\(safe\)/i.test(html) && + /textEl\.innerHTML\s*=\s*renderMentions\(raw\)/i.test(html); + addCheck({ + ok: hasPeopleCompanyTagContract, + name: "people and company tags project as typed public event-log context", + plane: "event-log", + detail: "@mentions + #tags render from public row decoration", + }); + if (!hasPeopleCompanyTagContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "event-log", + title: "People/company event-log tag contract is missing or incomplete", + path, + recommendation: + "Ensure public chat row decoration renders typed @mentions and #company/topic tags without deriving them from private notes.", + }); + } + + const hasPrivateAskBranchContract = + /function\s+parseComposerIntent\s*\(/i.test(html) && + /\/ask private[\s\S]{0,140}private notebook save[\s\S]{0,120}no public agent call/i.test(html) && + /private note or private \/ask[\s\S]{0,140}never touches public feed or public agent/i.test(html) && + /LIVE-006[\s\S]{0,180}private saves privately and does not invoke public agent/i.test(html); + addCheck({ + ok: hasPrivateAskBranchContract, + name: "home-v5 private /ask stays on private branch", + plane: "privacy", + detail: "/ask private -> private notebook save, no public feed, no public agent", + }); + if (!hasPrivateAskBranchContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "privacy", + title: "Private /ask branch contract is missing or incomplete", + path, + recommendation: + "Ensure /ask private is parsed as a private notebook save and never reaches public chat, public /ask, or the public agent runtime.", + }); + } + + const hasRoomWallArtifactContract = + /class=["']sn-pin["'][\s\S]{0,220}snWall\s*&&\s*window\.snWall\.pinAnswer/i.test(html) && + /window\.snSuggestFaq\s*&&\s*window\.snSuggestFaq/i.test(html) && + /window\.snPromoteFaq\s*&&\s*window\.snPromoteFaq/i.test(html) && + /className\s*=\s*['"]host-queue['"]/i.test(html) && + /Promote to FAQ/i.test(html) && + /className\s*=\s*['"]published-wiki-card['"]/i.test(html) && + /artifact\.published_event_wiki/i.test(html) && + /EventArchiveArtifact/i.test(html) && + /hostPromotedOnly:\s*true/i.test(html) && + /privateNotesExcluded:\s*true/i.test(html); + addCheck({ + ok: hasRoomWallArtifactContract, + name: "home-v5 room wall turns public answers into host-promoted wiki artifacts", + plane: "product-workflow", + detail: "pin answer + suggest FAQ + host queue promotion + published wiki artifact + private-note exclusion", + }); + if (!hasRoomWallArtifactContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "product-workflow", + title: "Room wall artifact contract is missing or incomplete", + path, + recommendation: + "Ensure public /ask answers can be pinned to the wall, suggested for FAQ, host-promoted, and compacted into an event-public wiki artifact that excludes private notes.", + }); + } + + const dailyBriefDeltaIndex = html.indexOf('class="daily-brief-delta"'); + const dailyBriefDeltaBlock = dailyBriefDeltaIndex >= 0 ? html.slice(dailyBriefDeltaIndex, dailyBriefDeltaIndex + 1800) : ""; + const hasDailyBriefDeltaContract = + /data-delta-source=["']event-artifact["']/i.test(dailyBriefDeltaBlock) && + /data-private-notes=["']workspace-only["']/i.test(dailyBriefDeltaBlock) && + /What changed/i.test(dailyBriefDeltaBlock) && + /Why it matters/i.test(dailyBriefDeltaBlock) && + /event wiki and public sources/i.test(dailyBriefDeltaBlock) && + /Private notes stay workspace-only/i.test(dailyBriefDeltaBlock); + addCheck({ + ok: hasDailyBriefDeltaContract, + name: "NodeBench Daily Brief delta explains changed event artifacts without public private notes", + plane: "nodebench-handoff", + detail: "what changed + why it matters + event artifact/wiki source + workspace-only private notes", + }); + if (!hasDailyBriefDeltaContract) { + addFinding({ + severity: "blocker", + safety: "human-gated", + plane: "nodebench-handoff", + title: "Daily Brief delta contract is missing or incomplete", + path, + recommendation: + "Ensure the NodeBench handoff explains what changed, why it matters, uses the event artifact/wiki as public source, and keeps private notes workspace-only.", + }); + } +} + +function scanBackendContracts() { + const events = readText(files.events); + const notes = readText(files.notes); + const users = readText(files.users); + const eventsRuntimeBoundarySpec = readText(files.eventsRuntimeBoundarySpec); + + const contracts = [ + { + name: "events.ts documents public/private boundary", + ok: /only handles PUBLIC chat/i.test(events) && /Private notes/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "provider prompt forbids private notes", + ok: /Do not use or mention private notes/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "answer trace states private notes are excluded", + ok: /private notes excluded/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "semantic cache trace shows source reuse and privacy boundary", + ok: + /step:\s*["']semantic_cache_lookup["'][\s\S]{0,360}status:\s*["']ok["'][\s\S]{0,360}source bundle unchanged[\s\S]{0,180}private notes excluded/i.test(events) && + /cacheHit:\s*true/i.test(events) && + /externalSearches:\s*0/i.test(events) && + /computeCacheSkipReason/i.test(events) && + /Cached answer skipped/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "ask work uses idempotency and rate limit preparation", + ok: /reserveAskSlot|_reserveAskSlot/i.test(events) && /ASK_RATE_LIMIT_PER_MIN/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "normal sendMessage path is separate from askAgent", + ok: /export const sendMessage = mutation/i.test(events) && /export const askAgent = action/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "host announcements stay host-gated no-LLM event-log messages", + ok: + /kind:\s*v\.union\([\s\S]*v\.literal\("system"\)/i.test(events) && + /args\.kind === "system"[\s\S]{0,360}requireHost\(ctx,\s*args\.eventId,\s*args\.ownerKey\)/i.test(events) && + /ctx\.db\.insert\("liveEventMessages"[\s\S]{0,260}kind:\s*args\.kind/i.test(events) && + /keeps host announcements as host-gated no-LLM event-log messages/i.test( + eventsRuntimeBoundarySpec, + ) && + /expect\(executableSendMessage\)\.not\.toContain\("askAgent"\)/i.test(eventsRuntimeBoundarySpec) && + /expect\(executableSendMessage\)\.not\.toContain\("liveEventWikiVersions"\)/i.test( + eventsRuntimeBoundarySpec, + ), + path: files.eventsRuntimeBoundarySpec, + blocker: true, + }, + { + name: "attendee check-ins stay no-LLM membership event-log moments", + ok: + /export const joinEvent = mutation/i.test(events) && + /ctx\.db\.insert\("liveEventMembers"[\s\S]{0,220}lastSeenAt:\s*now/i.test(events) && + /ctx\.db\.patch\(event\._id,\s*\{\s*lastActivityAt:\s*now\s*\}\)/i.test(events) && + /liveEventJoinRequests[\s\S]{0,360}request\.status !== "approved"/i.test(events) && + /keeps attendee check-ins as no-LLM membership event-log moments/i.test( + eventsRuntimeBoundarySpec, + ) && + /expect\(executableJoinEvent\)\.not\.toContain\("askAgent"\)/i.test( + eventsRuntimeBoundarySpec, + ) && + /expect\(executableJoinEvent\)\.not\.toContain\("liveEventMessages"\)/i.test( + eventsRuntimeBoundarySpec, + ), + path: files.eventsRuntimeBoundarySpec, + blocker: true, + }, + { + name: "host-only wiki promotion/publish gate exists", + ok: /(?:const|function)\s+requireHost/i.test(events) && /promoteAnswerToFaq/i.test(events) && /publishWiki/i.test(events), + path: files.events, + blocker: true, + }, + { + name: "private notes are owner-key scoped", + ok: /ownerKey/i.test(notes) && /createNote|listMyNotes/i.test(notes), + path: files.notes, + blocker: true, + }, + { + name: "ScratchNode sign-in user module exists", + ok: /Sign in to scratchnode\.live|magic/i.test(users), + path: files.users, + blocker: false, + }, + ]; + + for (const contract of contracts) { + addCheck({ + ok: contract.ok, + name: contract.name, + plane: "backend-contract", + detail: contract.path, + }); + if (!contract.ok) { + addFinding({ + severity: contract.blocker ? "blocker" : "warn", + safety: "human-gated", + plane: "backend-contract", + title: `Backend contract signal missing: ${contract.name}`, + path: contract.path, + recommendation: "Inspect the backend implementation and tests before launch.", + }); + } + } +} + +function scanPublicRepoReadiness() { + for (const relativePath of [ + files.exportScript, + files.goalLoopScript, + files.splitRunbook, + files.launchRunbook, + files.goalRunbook, + files.goalQueue, + files.scratchnodeGoal, + files.scratchnodeEventLogGoal, + files.nodebenchGoal, + files.runtimeGoal, + files.scratchnodeWikiServerless, + files.routeHonestySpec, + files.scratchnodePublicWikiSpec, + files.scratchnodeWikiBridgeSpec, + files.demoQa, + files.license, + files.security, + files.contributing, + ]) { + checkRequiredFile(relativePath); + } + + const exportScript = readText(files.exportScript); + const splitRunbook = readText(files.splitRunbook); + const readme = readText(files.readme); + const homeHtml = readText(files.homeV5); + const routeHonestySpec = readText(files.routeHonestySpec); + const scratchnodeWikiServerless = readText(files.scratchnodeWikiServerless); + const scratchnodePublicWikiSpec = readText(files.scratchnodePublicWikiSpec); + const scratchnodeWikiBridge = readText("src/features/events/views/ScratchnodeWikiBridge.tsx"); + const scratchnodeWikiBridgeSpec = readText(files.scratchnodeWikiBridgeSpec); + + addCheck({ + ok: /Explicit Exclusions/i.test(splitRunbook) && /convex\//i.test(splitRunbook), + name: "public split runbook documents monorepo exclusions", + plane: "public-repo", + detail: files.splitRunbook, + }); + addCheck({ + ok: /forbidden|sensitive|allowlist/i.test(exportScript), + name: "public export script scans allowlist/sensitive output", + plane: "public-repo", + detail: files.exportScript, + }); + addCheck({ + ok: + /eventLogProjections/i.test(exportScript) && + /publicEventLogJson/i.test(exportScript) && + /ownerPrivateNoteProjection/i.test(exportScript) && + /private notes/i.test(exportScript) && + /handoff tokens/i.test(exportScript) && + /public wiki JSON/i.test(exportScript), + name: "public export declares event-log and private projection boundaries", + plane: "public-repo", + detail: "publicEventLogJson + ownerPrivateNoteProjection", + }); + addCheck({ + ok: + /requiredPrivateIncludes/i.test(exportScript) && + /requiredPrivateExclusions/i.test(exportScript) && + /owner private notes/i.test(exportScript) && + /private note anchors/i.test(exportScript) && + /manual location spot anchors/i.test(exportScript) && + /private follow-ups/i.test(exportScript) && + /owner voice transcripts/i.test(exportScript) && + /NodeBench handoff context/i.test(exportScript) && + /public \/ask cache/i.test(exportScript) && + /other attendees' notes/i.test(exportScript), + name: "public export verifier enforces owner-only private projection", + plane: "public-repo", + detail: "requiredPrivateIncludes + requiredPrivateExclusions", + }); + addCheck({ + ok: + /open-source event log assistant/i.test(exportScript) && + /memory layer for live events/i.test(exportScript) && + /open-source event log assistant/i.test(splitRunbook) && + /memory layer for live events/i.test(splitRunbook), + name: "public export uses event-log assistant positioning", + plane: "public-repo", + detail: "open-source event log assistant + memory layer for live events", + }); + addCheck({ + ok: !/\bproduction[-\s]+(?:ready|grade)\b/i.test(`${exportScript}\n${splitRunbook}`), + name: "public export avoids final-production claims", + plane: "public-repo", + detail: "no final-production status claim in public export/runbook wording", + }); + addCheck({ + ok: /ScratchNode|NodeBench/i.test(readme), + name: "root README names product context", + plane: "public-repo", + detail: files.readme, + optional: true, + }); + addCheck({ + ok: + /normal public chat stays human and never invokes the agent/i.test(routeHonestySpec) && + /kind:\s*"chat"/i.test(routeHonestySpec) && + /events:sendMessage/i.test(routeHonestySpec), + name: "event-log route spec covers public timeline moments", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /attendee room join stays a no-LLM membership event without public projections/i.test( + routeHonestySpec, + ) && + /events:joinEvent/i.test(routeHonestySpec) && + /expect\(joinState\.messageCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.noteCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.wikiCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.askActions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(joinState\.publishedWiki\)\.toBeNull\(\)/i.test(routeHonestySpec), + name: "event-log route spec covers attendee join no-LLM boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /normal public replies stay chat-only event-log moments/i.test(routeHonestySpec) && + /replyToMessageId:\s*room\.replyingToMid\s*\|\|\s*undefined/i.test(homeHtml) && + /data-reply-to-message-id/i.test(homeHtml) && + /row-replying/i.test(homeHtml) && + /replySendCalls/i.test(routeHonestySpec) && + /expect\(replyState\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(replyState\.privateNoteCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(replyState\.askCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers public reply no-LLM boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /manual location spots render as public event-log chips without private leakage/i.test(routeHonestySpec) && + /Booth 12/i.test(routeHonestySpec) && + /Lobby/i.test(routeHonestySpec) && + /Panel Room A/i.test(routeHonestySpec) && + /Investor Lounge/i.test(routeHonestySpec) && + /Afterparty/i.test(routeHonestySpec) && + /data-location-spot/i.test(routeHonestySpec) && + /private notes anchored from manual location spots preserve context without public leakage/i.test( + routeHonestySpec, + ) && + /locationMarkerCount/i.test(routeHonestySpec) && + /anchorPreview:\s*publicText/i.test(routeHonestySpec) && + /navigator\.geolocation|getCurrentPosition|watchPosition/i.test(routeHonestySpec), + name: "event-log route spec covers manual location spot fixtures", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /typed people and company tags stay public-row context while private tagged follow-ups stay private/i.test( + routeHonestySpec, + ) && + /private notes anchored from people and company tags keep public ask context clean/i.test( + routeHonestySpec, + ) && + /data-event-log-tag/i.test(routeHonestySpec) && + /anchorPreview:\s*publicText/i.test(routeHonestySpec) && + /serializedAnswers[\s\S]{0,120}\.not\.toContain\("Sarah Kim"\)/i.test(routeHonestySpec) && + /serializedAnswers[\s\S]{0,120}\.not\.toContain\("MedLayer"\)/i.test(routeHonestySpec) && + /privateSendCalls/i.test(routeHonestySpec), + name: "event-log route spec covers tag visibility boundaries", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /function buildLiveAssistFollowUpNote\s*\(/i.test(homeHtml) && + /function saveLiveAssistPrivateNote\s*\(/i.test(homeHtml) && + /window\.saveLiveAssistPrivateNote\s*=\s*saveLiveAssistPrivateNote/i.test(homeHtml) && + /saveLiveAssistPrivateNote\('Cue: '\s*\+\s*cue\.text,\s*'cue'\)/i.test(homeHtml) && + /skill:\s*opts\.skill\s*\|\|\s*'meeting-live-cue'/i.test(homeHtml) && + /traceRef:\s*'trace_'\s*\+\s*cueId/i.test(homeHtml) && + /Cue source: '\s*\+\s*source/i.test(homeHtml) && + /Cue skill: '\s*\+\s*skill/i.test(homeHtml) && + /Cue trace: '\s*\+\s*traceRef/i.test(homeHtml) && + /NodeBench packet: people, companies, topics, anchors, source refs, and open questions\./i.test( + homeHtml, + ) && + /Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer\./i.test( + homeHtml, + ) && + /Visibility: private follow-up note; not public chat or public \/ask\./i.test(homeHtml) && + /Live Assist save cue writes an actual private note without public writes/i.test( + routeHonestySpec, + ) && + /_laCueAction\?\.\("save",\s*id\)/i.test(routeHonestySpec) && + /Cue: \$\{cueText\}/i.test(routeHonestySpec) && + /Live Assist ask privately cue drafts first, then sends only to private notes/i.test( + routeHonestySpec, + ) && + /expect\(draftState\.draft\)\.toBe\(`\/ask private \$\{cueText\}`\)/i.test(routeHonestySpec) && + /expect\(draftState\.inputEvents\)\.toBeGreaterThan\(0\)/i.test(routeHonestySpec) && + /Live Assist follow-up cues require explicit action before private note creation/i.test( + routeHonestySpec, + ) && + /expect\(beforeAction\.noteCount\)\.toBe\(initialNoteCount\)/i.test(routeHonestySpec) && + /expect\(beforeAction\.noteTexts\.join\("\\n"\)\)\.not\.toContain\(cueText\)/i.test( + routeHonestySpec, + ) && + /_laCueAction\?\.\("followup",\s*id\)/i.test(routeHonestySpec) && + /Follow-up: \$\{cueText\}/i.test(routeHonestySpec) && + /Cue source: route-test/i.test(routeHonestySpec) && + /Cue skill: follow-up-depth/i.test(routeHonestySpec) && + /Cue trace: trace_\$\{cueId\}/i.test(routeHonestySpec) && + /NodeBench packet: people, companies, topics, anchors, source refs, and open questions\./i.test( + routeHonestySpec, + ) && + /Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer\./i.test( + routeHonestySpec, + ) && + /publicSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers structured private follow-up cues", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /Live Assist voice transcript saves as a private note without public writes/i.test(routeHonestySpec) && + /laStartVoice/i.test(routeHonestySpec) && + /laUpdateVoice/i.test(routeHonestySpec) && + /saveLiveAssistPrivateNote/i.test(routeHonestySpec) && + /voiceInLiveAssist/i.test(routeHonestySpec) && + /voiceInFeed/i.test(routeHonestySpec) && + /recentVoiceNotes/i.test(routeHonestySpec) && + /expect\(savedState\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(savedState\.publicSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers voice transcript private-note boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /function\s+detectPublicPhotoEvidence\s*\(/i.test(homeHtml) && + /function\s+renderPublicPhotoEvidence\s*\(/i.test(homeHtml) && + /data-event-log-media["']?,\s*["']photo/i.test(homeHtml) && + /className\s*=\s*["']sn-photo-evidence["']/i.test(homeHtml) && + /public photo evidence markers stay event-log only while private photo follow-ups stay private/i.test( + routeHonestySpec, + ) && + /photo: Booth 12 latency board for #Orbital/i.test(routeHonestySpec) && + /sn-photo-evidence\[data-event-log-media="photo"\]/i.test(routeHonestySpec) && + /privateText\s*=\s*"photo: private sponsor board follow-up for MedLayer buyers"/i.test( + routeHonestySpec, + ) && + /expect\(state\.privateSendCalls\)\.toEqual\(\[\]\)/i.test(routeHonestySpec) && + /expect\(state\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec covers public photo evidence boundary", + plane: "event-log-evidence", + detail: `${files.homeV5} + ${files.routeHonestySpec}`, + }); + addCheck({ + ok: + /private notes anchored from public messages preserve context without public leakage/i.test(routeHonestySpec) && + /anchorType:\s*"message"/i.test(routeHonestySpec) && + /private-note-marker/i.test(routeHonestySpec), + name: "event-log route spec covers private note anchors", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /private notes anchored from public answers preserve context without public leakage/i.test(routeHonestySpec) && + /targetKind:\s*"answer"/i.test(routeHonestySpec) && + /targetAnswerId:\s*answerId/i.test(routeHonestySpec) && + /sn-anchor-pin/i.test(routeHonestySpec) && + /serializedAnswers\)\.not\.toContain\(privateText\)/i.test(routeHonestySpec), + name: "event-log route spec covers private note answer anchors", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /verified host publishes promoted public answers into the wiki without leaking private notes/i.test( + routeHonestySpec, + ) && + /__snMockPublishedWiki/i.test(routeHonestySpec) && + /not\.toContain\(privateNoteText\)/i.test(routeHonestySpec), + name: "event-log route spec covers public wiki projection boundary", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /verified host can manage room metadata, public sources, and end session/i.test(routeHonestySpec) && + /events:updateEvent/i.test(routeHonestySpec) && + /events:upsertEventSource/i.test(routeHonestySpec) && + /events:deleteEventSource/i.test(routeHonestySpec) && + /events:endEvent/i.test(routeHonestySpec) && + /hostWorkflowState\.actions/i.test(routeHonestySpec) && + /expect\(hostWorkflowState\.actions\)\.toEqual\(\[\]\)/i.test(routeHonestySpec), + name: "event-log route spec keeps host source management no-LLM by default", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /NodeBench handoff has a tokenized private route and an honest shipped fallback/i.test(routeHonestySpec) && + /buildNodeBenchTokenizedPrivateUrl/i.test(routeHonestySpec) && + /publicArtifact=event-wiki/i.test(routeHonestySpec), + name: "event-log route spec covers NodeBench handoff separation", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /NodeBench handoff keeps private follow-up text, tags, and anchors out of visibility-safe URLs/i.test( + routeHonestySpec, + ) && + /expect\(urls\.fallbackKeys\)\.toEqual\(\[\s*"continuation",\s*"event",\s*"noteCount",\s*"publicArtifact",\s*"return",\s*"room",\s*"source",\s*\]\)/i.test( + routeHonestySpec, + ) && + /expect\(urls\.tokenizedKeys\)\.toEqual\(\[\s*"room",\s*"source",\s*"token"\s*\]\)/i.test( + routeHonestySpec, + ) && + /expect\(urls\.fallbackParams\)\.toMatchObject\(\{[\s\S]*publicArtifact:\s*"event-wiki"[\s\S]*return:\s*"https:\/\/scratchnode\.live\/e\/ai-infra-summit-2026"/i.test( + routeHonestySpec, + ) && + /expect\(urls\.tokenizedParams\)\.toEqual\(\{[\s\S]*token:\s*"qa-sentinel-token-1111111111"/i.test( + routeHonestySpec, + ) && + /expect\(urls\.fallback\)\.not\.toContain\(urls\.publicCompany\)/i.test(routeHonestySpec) && + /expect\(urls\.tokenized\)\.not\.toContain\(encodeURIComponent\(urls\.publicTopic\)\)/i.test( + routeHonestySpec, + ) && + /expect\(urls\.fallback\)\.not\.toContain\(urls\.sessionId\)/i.test(routeHonestySpec) && + /expect\(urls\.tokenized\)\.not\.toContain\(urls\.anchorId\)/i.test(routeHonestySpec), + name: "event-log route spec proves visibility-safe NodeBench handoff URLs", + plane: "event-log-evidence", + detail: files.routeHonestySpec, + }); + addCheck({ + ok: + /keeps public wiki bridge links visibility-safe and free of private handoff params/i.test( + scratchnodeWikiBridgeSpec, + ) && + /https:\/\/scratchnode\.live\/wiki\/rooftop-launch/i.test(scratchnodeWikiBridgeSpec) && + /https:\/\/scratchnode\.live\/e\/rooftop/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("token="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("session="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("continuation="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("publicArtifact="\)/i.test(scratchnodeWikiBridgeSpec) && + /not\.toContain\("noteCount="\)/i.test(scratchnodeWikiBridgeSpec), + name: "NodeBench public wiki bridge links stay visibility-safe", + plane: "event-log-evidence", + detail: files.scratchnodeWikiBridgeSpec, + }); + addCheck({ + ok: + /ScratchNode-owned public wiki SSR route/i.test(scratchnodeWikiServerless) && + /must not mint or accept NodeBench private handoff tokens/i.test(scratchnodeWikiServerless) && + /NodeBench-owned receiver/i.test(scratchnodeWikiServerless) && + /api\.events\.getPublishedWikiBySlug/i.test(scratchnodeWikiServerless) && + /Private notes are excluded/i.test(scratchnodeWikiServerless) && + /NodeBench-owned bridge\/conversion surface/i.test(scratchnodeWikiBridge) && + /not the[\s\S]{0,120}ScratchNode-owned public wiki SSR reader/i.test(scratchnodeWikiBridge) && + /must not duplicate[\s\S]{0,120}ScratchNode publishing\/SSR ownership/i.test(scratchnodeWikiBridge) && + /function\s+buildNodeBenchPublicWikiUrl\s*\(\s*wiki\s*\)/i.test(homeHtml) && + /id=["']sn-wiki-nb["']/i.test(homeHtml) && + /\/events\/' \+ encodeURIComponent\(slug\) \+ '\/wiki'/i.test(homeHtml) && + /\?source=scratchnode/i.test(homeHtml) && + /https:\/\/nodebenchai\.com\/events\/rooftop-launch\/wiki\?source=scratchnode&room=ROOFTOP/i.test( + scratchnodePublicWikiSpec, + ) && + /expect\(nodeBenchHref\)\.not\.toContain\("token="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("session="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("continuation="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("publicArtifact="\)/i.test(scratchnodePublicWikiSpec) && + /expect\(nodeBenchHref\)\.not\.toContain\("noteCount="\)/i.test(scratchnodePublicWikiSpec), + name: "ScratchNode and NodeBench wiki readers have explicit ownership split", + plane: "event-log-evidence", + detail: `${files.scratchnodeWikiServerless} + src/features/events/views/ScratchnodeWikiBridge.tsx`, + }); +} + +function scanGoalAutomationReadiness() { + const packageJson = readText("package.json"); + const goalRunbook = readText(files.goalRunbook); + const goalLoopScript = readText(files.goalLoopScript); + const launchRunbook = readText(files.launchRunbook); + + const checks = [ + { + name: "package exposes ScratchNode launch goal loop script", + ok: /"scratchnode:launch:goal"\s*:\s*"node scripts\/scratchnode\/runLaunchGoalLoop\.mjs"/i.test(packageJson), + path: "package.json", + blocker: true, + detail: "scratchnode:launch:goal", + }, + { + name: "goal loop runs housekeeping and launch interaction gates", + ok: /repo:housekeeping:check/i.test(goalLoopScript) && /scratchnode:launch:interactive/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "housekeeping + static/live/interactive launch checks", + }, + { + name: "goal loop writes durable .tmp report", + ok: /scratchnode-launch-goal-loop\.json/i.test(goalLoopScript) && /notifyRecommended/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: ".tmp/scratchnode-launch-goal-loop.json", + }, + { + name: "goal loop carries continuous development backlog", + ok: + /developmentBacklog/i.test(goalLoopScript) && + /nextDevelopmentCandidate/i.test(goalLoopScript) && + /safe-local-development/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "developmentBacklog + nextDevelopmentCandidate", + }, + { + name: "goal loop reads durable repo goal queue", + ok: /readGoalQueue/i.test(goalLoopScript) && /goalQueue/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "goals/**/*.md -> goalQueue", + }, + { + name: "goal loop ignores non-card Markdown queue docs", + ok: + /frontmatter\.id/i.test(goalLoopScript) && + /frontmatter\.status/i.test(goalLoopScript) && + /frontmatter\.mode/i.test(goalLoopScript) && + /\.filter\(Boolean\)/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "requires explicit goal-card frontmatter before backlog inclusion", + }, + { + name: "goal loop covers both ScratchNode and NodeBench improvement axes", + ok: /ScratchNode product workflow/i.test(goalLoopScript) && /NodeBench handoff/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "ScratchNode product workflow + NodeBench handoff", + }, + { + name: "goal loop preserves production mutation boundary", + ok: + /read-only against production/i.test(goalLoopScript) && + /sending chat|creating events|publishing wikis|mutating live user data/i.test(goalLoopScript), + path: files.goalLoopScript, + blocker: true, + detail: "no live chat/event/wiki mutations", + }, + { + name: "goal runbook treats /goal as stop condition", + ok: /goal is not a normal prompt/i.test(goalRunbook) && /stop condition/i.test(goalRunbook), + path: files.goalRunbook, + blocker: true, + detail: files.goalRunbook, + }, + { + name: "goal runbook captures self-directed workflow pattern", + ok: + /batched issue queue/i.test(goalRunbook) && + /specialist passes/i.test(goalRunbook) && + /cost\/effort accounting/i.test(goalRunbook), + path: files.goalRunbook, + blocker: false, + detail: "batch queue + focused passes + cost accounting", + }, + { + name: "launch runbook includes goal-loop cron command", + ok: /scratchnode:launch:goal/i.test(launchRunbook), + path: files.launchRunbook, + blocker: false, + detail: files.launchRunbook, + }, + ]; + + for (const check of checks) { + addCheck({ + ok: check.ok, + name: check.name, + plane: "goal-automation", + detail: check.detail, + optional: !check.blocker, + }); + if (!check.ok) { + addFinding({ + severity: check.blocker ? "blocker" : "warn", + safety: "human-gated", + plane: "goal-automation", + title: `Goal automation signal missing: ${check.name}`, + path: check.path, + recommendation: "Restore the goal-loop contract before relying on unattended launch automation.", + }); + } + } +} + +async function fetchWithTimeout(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000); + const started = performance.now(); + try { + const response = await fetch(url, { + redirect: "follow", + headers: { + "user-agent": "nodebench-scratchnode-launch-scan/1.0", + ...(options.headers ?? {}), + }, + signal: controller.signal, + }); + const contentType = response.headers.get("content-type") ?? ""; + const body = options.body === false ? "" : await response.text(); + return { + ok: response.ok, + status: response.status, + url: response.url, + contentType, + body, + durationMs: performance.now() - started, + }; + } finally { + clearTimeout(timeout); + } +} + +async function runHttpCheck(name, url, validate, options = {}) { + try { + const result = await fetchWithTimeout(url, options); + const validation = validate(result); + addLiveCheck({ + ok: result.ok && validation.ok, + name, + url, + status: result.status, + durationMs: result.durationMs, + detail: validation.detail, + optional: options.optional, + }); + } catch (error) { + addLiveCheck({ + ok: false, + name, + url, + detail: error instanceof Error ? error.message : String(error), + optional: options.optional, + }); + } +} + +function isInteractiveNetworkDenied(detail) { + return /ERR_NETWORK_ACCESS_DENIED|Network access denied/i.test(detail ?? ""); +} + +function isLiveNetworkDenied(detail) { + return isInteractiveNetworkDenied(detail) || /fetch failed/i.test(detail ?? ""); +} + +function summarizeRemoteProbeInfra({ liveFailures, interactiveFailures }) { + const interactiveNetworkDenied = + shouldRunInteractive && + interactiveChecks.length > 0 && + interactiveFailures.length === interactiveChecks.length && + interactiveFailures.every((check) => isInteractiveNetworkDenied(check.detail)); + const liveNetworkDenied = + shouldRunLive && + liveChecks.length > 0 && + liveFailures.length === liveChecks.length && + liveFailures.every((check) => isLiveNetworkDenied(check.detail)); + const networkAccessDenied = interactiveNetworkDenied && (!shouldRunLive || liveNetworkDenied); + + return { + networkAccessDenied, + liveNetworkDenied, + interactiveNetworkDenied, + reason: networkAccessDenied ? "remote probes blocked by local network restrictions" : "", + suppressedLiveFailures: networkAccessDenied ? liveFailures.length : 0, + suppressedInteractiveFailures: networkAccessDenied ? interactiveFailures.length : 0, + }; +} + +function headSignals(html) { + const head = html.match(//i)?.[0] ?? html.slice(0, 8000); + return { + head, + title: head.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? "", + canonical: head.match(/]*rel=["']canonical["'][^>]*>/i)?.[0] ?? "", + ogTitle: head.match(/]*property=["']og:title["'][^>]*>/i)?.[0] ?? "", + ogDescription: head.match(/]*property=["']og:description["'][^>]*>/i)?.[0] ?? "", + }; +} + +async function runLiveChecks() { + await runHttpCheck("scratchnode.live apex raw HTML", "https://scratchnode.live/", (result) => { + const signals = headSignals(result.body); + const ok = + /ScratchNode/i.test(signals.title) && + /scratchnode\.live/i.test(signals.canonical) && + /ScratchNode/i.test(signals.ogTitle) && + !/AI Infra Summit/i.test(signals.title + signals.ogTitle + signals.ogDescription + signals.canonical); + return { + ok, + detail: `title=${JSON.stringify(signals.title)}, canonical=${signals.canonical.slice(0, 140)}`, + }; + }); + + await runHttpCheck("scratchnode.live event route shell", "https://scratchnode.live/e/ai-infra-summit-2026", (result) => { + const signals = headSignals(result.body); + return { + ok: /ScratchNode/i.test(signals.title) && / { + const signals = headSignals(result.body); + return { + ok: /ScratchNode/i.test(signals.title) && !/demo_ver/i.test(result.url), + detail: `finalUrl=${result.url}, title=${JSON.stringify(signals.title)}`, + }; + }); + + await runHttpCheck("scratchnode.live demo route reachable", "https://scratchnode.live/demo_ver1", (result) => ({ + ok: /demoVerMatch|runDemoFull|ScratchNode/i.test(result.body), + detail: `bytes=${result.body.length}`, + })); + + await runHttpCheck("scratchnode public config endpoint", "https://scratchnode.live/api/scratchnode-config", (result) => { + try { + const json = JSON.parse(result.body); + const keys = Object.keys(json).sort(); + const hasOnlyPublicShape = + keys.includes("convexUrl") && + !keys.some((key) => /secret|token|key|password/i.test(key)); + return { ok: hasOnlyPublicShape, detail: `keys=${keys.join(",")}` }; + } catch { + return { ok: false, detail: "response is not JSON" }; + } + }); + + await runHttpCheck("scratchnode OG image", "https://scratchnode.live/og-scratchnode.png", (result) => ({ + ok: /image\/png/i.test(result.contentType) && result.body.length > 1000, + detail: `contentType=${result.contentType}, bytes=${result.body.length}`, + })); + + await runHttpCheck("scratchnode missing-room route shell", "https://scratchnode.live/e/zzz-does-not-exist-zzz", (result) => ({ + ok: /ScratchNode/i.test(headSignals(result.body).title) && / { + const signals = headSignals(result.body); + return { + ok: /NodeBench/i.test(signals.title) || /id=["']root["']/i.test(result.body), + detail: `finalUrl=${result.url}, title=${JSON.stringify(signals.title)}`, + }; + }); + + await runHttpCheck("www.nodebenchai.com apex", "https://www.nodebenchai.com/", (result) => { + const signals = headSignals(result.body); + return { + ok: /NodeBench/i.test(signals.title) || /id=["']root["']/i.test(result.body), + detail: `finalUrl=${result.url}, title=${JSON.stringify(signals.title)}`, + }; + }); + + await runHttpCheck("nodebenchai.com scratchnode-events route", "https://nodebenchai.com/scratchnode-events", (result) => ({ + ok: /id=["']root["']/i.test(result.body) && !/sidecar event rooms with memory/i.test(headSignals(result.body).title), + detail: `finalUrl=${result.url}, title=${JSON.stringify(headSignals(result.body).title)}`, + })); +} + +async function runInteractiveChecks() { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + addInteractiveCheck({ + ok: false, + name: "Playwright import", + url: "local", + detail: error instanceof Error ? error.message : String(error), + optional: true, + }); + return; + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + userAgent: "nodebench-scratchnode-launch-scan/interactive", + }); + + async function pageCheck(name, url, validate) { + const page = await context.newPage(); + const consoleErrors = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + const started = performance.now(); + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + const result = await validate(page, consoleErrors); + addInteractiveCheck({ + ok: !!result.ok, + name, + url, + durationMs: performance.now() - started, + detail: result.detail, + }); + } catch (error) { + addInteractiveCheck({ + ok: false, + name, + url, + durationMs: performance.now() - started, + detail: error instanceof Error ? error.message : String(error), + }); + } finally { + await page.close().catch(() => {}); + } + } + + await pageCheck("scratchnode apex interactive landing", "https://scratchnode.live/", async (page, consoleErrors) => { + await page.waitForSelector("body", { timeout: 10_000 }); + const data = await page.evaluate(() => ({ + title: document.title, + pageMode: document.body.getAttribute("data-page-mode"), + hasJoinInput: !!document.querySelector("#landing-code, #ci"), + buttonCount: document.querySelectorAll("button").length, + })); + return { + ok: /ScratchNode/i.test(data.title) && data.hasJoinInput && data.buttonCount > 0, + detail: `title=${JSON.stringify(data.title)}, pageMode=${data.pageMode}, buttons=${data.buttonCount}, consoleErrors=${consoleErrors.length}`, + }; + }); + + await pageCheck("scratchnode event route interactive", "https://scratchnode.live/e/ai-infra-summit-2026?demo=1", async (page) => { + await page.waitForFunction(() => document.body.getAttribute("data-page-mode") === "event", null, { timeout: 15_000 }); + const data = await page.evaluate(() => ({ + pageMode: document.body.getAttribute("data-page-mode"), + live: document.body.getAttribute("data-sn-live"), + fullDemoAllowed: globalThis.shouldRunScratchNodeFullDemo?.(), + composerDisabled: document.querySelector("#ci")?.hasAttribute("disabled") ?? null, + hasAskHint: /\/ask/i.test(document.body.textContent ?? ""), + })); + return { + ok: data.pageMode === "event" && data.fullDemoAllowed === false && data.hasAskHint, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("scratchnode event workflow affordances", "https://scratchnode.live/e/ai-infra-summit-2026", async (page) => { + await page.waitForFunction(() => document.body.getAttribute("data-page-mode") === "event", null, { timeout: 15_000 }); + const data = await page.evaluate(() => { + const bodyText = document.body.textContent ?? ""; + const buttonsAndLinks = [...document.querySelectorAll("button, a")] + .map((node) => node.textContent?.replace(/\s+/g, " ").trim() ?? "") + .filter(Boolean); + const isVisible = (node) => { + const style = getComputedStyle(node); + return style.display !== "none" && + style.visibility !== "hidden" && + node.getClientRects().length > 0; + }; + const visibleButtonsAndLinks = [...document.querySelectorAll("button, a")] + .filter(isVisible) + .map((node) => node.textContent?.replace(/\s+/g, " ").trim() ?? "") + .filter(Boolean); + const rowTexts = [...document.querySelectorAll(".row-text")] + .map((node) => node.textContent?.trim() ?? "") + .filter(Boolean); + const answerQuestions = [...document.querySelectorAll(".ans-q")] + .map((node) => node.textContent?.trim() ?? "") + .filter(Boolean); + const answerTexts = [...document.querySelectorAll(".ans")] + .map((node) => node.textContent?.replace(/\s+/g, " ").trim() ?? "") + .filter(Boolean); + const firstFlowSteps = [...document.querySelectorAll("[data-first-time-flow] [data-flow-step]")] + .map((node) => ({ + step: node.getAttribute("data-flow-step") ?? "", + text: node.textContent?.replace(/\s+/g, " ").trim() ?? "", + })) + .filter((item) => item.step); + const normalizeQuestion = (text) => text.trim().replace(/[?.!]+$/, "").toLowerCase(); + const askQuestions = rowTexts + .filter((text) => /^\/ask\b/i.test(text)) + .map((text) => normalizeQuestion(text.replace(/^\/ask\b/i, ""))) + .filter(Boolean); + const composerPlaceholder = document.querySelector("#ci")?.getAttribute("placeholder") ?? ""; + const visibleActionText = `${visibleButtonsAndLinks.join(" | ")} ${composerPlaceholder}`; + const firstFlowStepOrder = firstFlowSteps.map((item) => item.step).join("|"); + const hasPrivateNotesAffordance = + !!document.querySelector("#lock") && + /My private notes|private notes/i.test(bodyText); + return { + composerPlaceholder, + hasAskParentRow: rowTexts.some((text) => /^\/ask\b/i.test(text)), + hasNestedAnswer: askQuestions.some((question) => + answerQuestions.some((answerQuestion) => normalizeQuestion(answerQuestion) === question), + ), + allAgentAnswersHaveAskParents: + answerQuestions.length > 0 && + answerQuestions.every((answerQuestion) => askQuestions.includes(normalizeQuestion(answerQuestion))), + hasPublicTraceBoundary: answerTexts.some((text) => /no private notes|private notes excluded/i.test(text)), + hasSharedAnswerCostSummary: answerTexts.some((text) => + /Answered from event wiki/i.test(text) && + /\d+\s+similar questions/i.test(text) && + /\d+\s+sources reused/i.test(text) && + /0\s+new searches/i.test(text), + ), + hasTraceHonestySteps: answerTexts.some((text) => + /event wiki cache/i.test(text) && + /semantic cache/i.test(text) && + /No private notes used|public layer only/i.test(text), + ), + firstFlowSteps, + visibleButtonsAndLinks: visibleButtonsAndLinks.slice(0, 16), + firstFlowStepOrder, + hasOrderedFirstFlow: firstFlowStepOrder === "join|chat|ask|private-note|wiki", + hasVisibleFirstFlowAffordances: + /ORBITAL|room code/i.test(visibleActionText) && + /\bChat\b/i.test(visibleActionText) && + /\/ask|Ask the first question/i.test(visibleActionText) && + /private notes|\bnotes\b|🔒/i.test(visibleActionText) && + /open wiki|view in wiki/i.test(visibleActionText), + hasVisibleFirstFlowAffordancesFromControls: + /ORBITAL|room code/i.test(visibleActionText) && + /\bChat\b/i.test(visibleActionText) && + /\/ask|Ask the first question/i.test(visibleActionText) && + hasPrivateNotesAffordance && + /open wiki|view in wiki/i.test(visibleActionText), + hasFaqSuggestion: buttonsAndLinks.some((text) => /suggest for faq/i.test(text)), + hasHostFaqPromotion: buttonsAndLinks.some((text) => /promote to faq/i.test(text)), + hasWikiContinuation: buttonsAndLinks.some((text) => /open wiki|view in wiki/i.test(text)), + hasPrivateNotesAffordance, + }; + }); + const ok = + /\/ask/i.test(data.composerPlaceholder) && + data.hasAskParentRow && + data.hasNestedAnswer && + data.allAgentAnswersHaveAskParents && + data.hasPublicTraceBoundary && + data.hasSharedAnswerCostSummary && + data.hasTraceHonestySteps && + (data.hasOrderedFirstFlow || data.hasVisibleFirstFlowAffordances || data.hasVisibleFirstFlowAffordancesFromControls) && + data.hasFaqSuggestion && + data.hasHostFaqPromotion && + data.hasWikiContinuation && + data.hasPrivateNotesAffordance; + return { + ok, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("scratchnode event interactive components", "https://scratchnode.live/e/ai-infra-summit-2026", async (page) => { + await page.waitForFunction(() => typeof globalThis.openWiki === "function", null, { timeout: 15_000 }); + const data = await page.evaluate(async () => { + const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); + const readSheetTitle = () => document.querySelector("#sheet-title")?.textContent?.trim() ?? ""; + const readSheetText = () => document.querySelector("#sheet-content")?.textContent?.trim() ?? ""; + const close = () => { + if (typeof globalThis.closeSheet === "function") globalThis.closeSheet(); + }; + + const functions = [ + "openWiki", + "openPeople", + "openShare", + "openNotes", + "openMenu", + "closeMenu", + "openNodeBenchPrivateHandoff", + "buildNodeBenchEventPrivateUrl", + "buildNodeBenchSignInUrl", + "toggleLock", + "pushLiveAssistCue", + "openModePicker", + "openCaptureLevelPicker", + "setEventMode", + "setCaptureLevel", + "copyRoom", + "copyShareUrl", + ].filter((name) => typeof globalThis[name] === "function"); + + globalThis.openWiki(); + await sleep(250); + const wikiTitle = readSheetTitle(); + const wikiText = readSheetText(); + close(); + + globalThis.openPeople(); + await sleep(250); + const peopleTitle = readSheetTitle(); + const peopleText = readSheetText(); + close(); + + globalThis.openShare(); + await sleep(250); + const shareTitle = readSheetTitle(); + const shareText = readSheetText(); + const shareUrl = document.querySelector(".share-url-box code")?.textContent?.trim() ?? ""; + const shareCopyButtonText = document.querySelector(".share-url-box button")?.textContent?.trim() ?? ""; + const shareHasPublicEventUrl = /https:\/\/scratchnode\.live\/e\/ai-infra-summit-2026/i.test(shareUrl || shareText); + const shareHasCopyAction = /copy/i.test(shareCopyButtonText); + const shareHasRoomCode = /Live room code\s+ORBITAL|code\s+ORBITAL|ORBITAL/i.test(shareText); + const shareQrImage = [...document.querySelectorAll("#sheet-content img")] + .map((img) => ({ + alt: img.getAttribute("alt") ?? "", + src: img.getAttribute("src") ?? "", + })) + .find((img) => /qr/i.test(img.alt) || /create-qr-code/i.test(img.src)); + const shareQrTargetsRoom = + !!shareQrImage && + /create-qr-code/i.test(shareQrImage.src) && + /scratchnode\.live%2Fe%2Fai-infra-summit-2026/i.test(shareQrImage.src) && + /room%3DORBITAL/i.test(shareQrImage.src); + const shareHasMobileQrPrompt = + /scan to join on mobile|scan|qr code|mobile/i.test(shareText) || + (!!shareQrImage && /qr code|scan/i.test(shareQrImage.alt)); + const shareHasSocialActions = ["Post on X", "LinkedIn", "Email", "More"] + .every((label) => new RegExp(label, "i").test(shareText)); + const copiedTexts = []; + let copyStubReady = false; + try { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText(text) { + copiedTexts.push(String(text)); + globalThis.__scratchNodeLaunchCopiedTexts = copiedTexts.slice(); + return Promise.resolve(); + }, + }, + }); + Object.defineProperty(navigator, "share", { + configurable: true, + value: undefined, + }); + copyStubReady = true; + } catch { + copyStubReady = false; + } + const shareCopyButton = document.querySelector(".share-url-box button"); + if (copyStubReady && shareCopyButton instanceof HTMLButtonElement) { + shareCopyButton.click(); + await sleep(100); + } + const shareCopyWritesEventUrl = copiedTexts.some((text) => + text === shareUrl && + /https:\/\/scratchnode\.live\/e\/ai-infra-summit-2026/i.test(text), + ); + close(); + + const roomCodeButton = document.querySelector("#sn-room-code-btn"); + if (copyStubReady && roomCodeButton instanceof HTMLButtonElement) { + roomCodeButton.click(); + await sleep(100); + } + const roomCodeCopyWritesJoinContext = copiedTexts.some((text) => + /Join\s+AI Infra Summit\s+on ScratchNode/i.test(text) && + /Code:\s*ORBITAL/i.test(text) && + /https:\/\/scratchnode\.live\/e\/ai-infra-summit-2026/i.test(text), + ); + + globalThis.openNotes(); + await sleep(250); + const notesText = readSheetText(); + close(); + + const liveAssistControllerReady = + typeof globalThis.toggleLiveAssist === "function" && + typeof globalThis.pushLiveAssistCue === "function"; + let liveAssistClosedBefore = false; + let liveAssistOpened = false; + let liveAssistToggleOn = false; + let liveAssistCueRendered = false; + let liveAssistCueLeakedToFeed = false; + let liveAssistClosedAfter = false; + if (liveAssistControllerReady) { + const cueText = "Launch scan private cue - do not publish"; + const rail = document.querySelector("#live-assist-rail"); + const sheet = document.querySelector("#live-assist-sheet"); + const toggle = document.querySelector("#la-toggle"); + const feedText = () => document.querySelector("#feed")?.textContent ?? ""; + const surfaceText = () => [ + rail?.textContent ?? "", + sheet?.textContent ?? "", + ].join(" "); + + globalThis.toggleLiveAssist(false); + await sleep(75); + liveAssistClosedBefore = + toggle?.getAttribute("data-on") === "false" && + document.body.getAttribute("data-la-open") === "false" && + rail?.getAttribute("data-open") !== "true" && + sheet?.getAttribute("data-open") !== "true"; + + globalThis.pushLiveAssistCue(cueText, { source: "launch-scan", skill: "launch-scan" }); + globalThis.toggleLiveAssist(true); + await sleep(150); + liveAssistOpened = + (rail?.getAttribute("data-open") === "true" && rail?.getAttribute("aria-hidden") === "false") || + (sheet?.getAttribute("data-open") === "true" && sheet?.getAttribute("aria-hidden") === "false"); + liveAssistToggleOn = + toggle?.getAttribute("data-on") === "true" && + toggle?.getAttribute("aria-pressed") === "true" && + document.body.getAttribute("data-la-open") === "true"; + liveAssistCueRendered = surfaceText().includes(cueText); + liveAssistCueLeakedToFeed = feedText().includes(cueText); + + globalThis.toggleLiveAssist(false); + await sleep(75); + liveAssistClosedAfter = + toggle?.getAttribute("data-on") === "false" && + toggle?.getAttribute("aria-pressed") === "false" && + document.body.getAttribute("data-la-open") === "false" && + rail?.getAttribute("data-open") !== "true" && + sheet?.getAttribute("data-open") !== "true"; + } + + const eventModeControllerReady = + typeof globalThis.openModePicker === "function" && + typeof globalThis.setEventMode === "function"; + let eventModeCycleOk = false; + let eventModeResetOk = false; + let initialEventOk = false; + let workModeOk = false; + let sensitiveModeOk = false; + let eventModeReturnedOk = false; + const eventModeSteps = []; + if (eventModeControllerReady) { + const modeLabel = () => document.querySelector("#ev-mode-label")?.textContent?.trim() ?? ""; + const composerPlaceholder = () => document.querySelector("#ci")?.getAttribute("placeholder") ?? ""; + const bodyMode = () => document.body.getAttribute("data-event-mode") ?? ""; + const modeSnapshot = (step) => { + const snapshot = { + step, + bodyMode: bodyMode(), + label: modeLabel(), + placeholder: composerPlaceholder(), + }; + eventModeSteps.push(snapshot); + return snapshot; + }; + const waitForMode = async (expectedMode, labelPattern, placeholderPattern) => { + for (let attempt = 0; attempt < 8; attempt += 1) { + const snapshot = modeSnapshot(`${expectedMode}:wait-${attempt}`); + if ( + snapshot.bodyMode === expectedMode && + labelPattern.test(snapshot.label) && + placeholderPattern.test(snapshot.placeholder) + ) { + return true; + } + await sleep(75); + } + return false; + }; + const cyclePickerToMode = async (expectedMode, labelPattern, placeholderPattern) => { + for (let attempt = 0; attempt < 3; attempt += 1) { + globalThis.openModePicker(); + modeSnapshot(`${expectedMode}:picker-${attempt}`); + if (await waitForMode(expectedMode, labelPattern, placeholderPattern)) return true; + } + return false; + }; + + globalThis.setEventMode("event"); + initialEventOk = await waitForMode("event", /Event/i, /\/ask/i); + + workModeOk = await cyclePickerToMode("work", /Work/i, /Visible|meeting room|team/i); + + sensitiveModeOk = await cyclePickerToMode("sensitive", /Sensitive/i, /Manual capture only/i); + + eventModeReturnedOk = await cyclePickerToMode("event", /Event/i, /\/ask/i); + eventModeCycleOk = + workModeOk && + sensitiveModeOk && + eventModeReturnedOk; + + globalThis.setEventMode("event"); + eventModeResetOk = await waitForMode("event", /Event/i, /\/ask/i); + } + + const captureLevelControllerReady = + typeof globalThis.openCaptureLevelPicker === "function" && + typeof globalThis.setCaptureLevel === "function" && + typeof globalThis.toggleLiveAssist === "function"; + let capturePickerOpened = false; + let captureLevelOneOk = false; + let captureLevelTwoBlocked = false; + let captureLevelResetOk = false; + if (captureLevelControllerReady) { + const rail = document.querySelector("#live-assist-rail"); + const sheet = document.querySelector("#live-assist-sheet"); + const capLabel = () => document.querySelector("#ev-cap-label")?.textContent?.trim() ?? ""; + const capLevel = () => document.body.getAttribute("data-capture-level") ?? ""; + const capButtons = (level) => [...document.querySelectorAll(`.la-cap-row button[data-cap-level="${level}"]`)]; + const capPressed = (level, expected) => + capButtons(level).length >= 2 && + capButtons(level).every((button) => button.getAttribute("aria-pressed") === expected); + + globalThis.setCaptureLevel(0); + globalThis.toggleLiveAssist(false); + await sleep(75); + globalThis.openCaptureLevelPicker(); + await sleep(150); + capturePickerOpened = + ((rail?.getAttribute("data-open") === "true" && rail?.getAttribute("aria-hidden") === "false") || + (sheet?.getAttribute("data-open") === "true" && sheet?.getAttribute("aria-hidden") === "false")) && + document.body.getAttribute("data-la-open") === "true" && + capButtons(0).length >= 2 && + capButtons(1).length >= 2 && + capButtons(2).length >= 2; + + globalThis.setCaptureLevel(1); + await sleep(50); + captureLevelOneOk = + capLevel() === "1" && + /L1\s+User-side/i.test(capLabel()) && + capPressed("1", "true") && + capPressed("0", "false") && + capPressed("2", "false"); + + globalThis.setCaptureLevel(2); + await sleep(50); + captureLevelTwoBlocked = + capLevel() === "1" && + /L1\s+User-side/i.test(capLabel()) && + capPressed("2", "false"); + + globalThis.setCaptureLevel(0); + globalThis.toggleLiveAssist(false); + await sleep(75); + captureLevelResetOk = + capLevel() === "0" && + /L0\s+Manual/i.test(capLabel()) && + capPressed("0", "true") && + document.body.getAttribute("data-la-open") === "false"; + } + + const lock = document.querySelector("#lock"); + const beforePrivate = lock?.getAttribute("data-on"); + globalThis.toggleLock(); + await sleep(50); + const afterPrivate = lock?.getAttribute("data-on"); + globalThis.toggleLock(); + + const wallEl = document.querySelector("#sn-wall"); + const wallControllerReady = !!globalThis.snWall && typeof globalThis.snWall.show === "function"; + const wallTabVisible = [...document.querySelectorAll("[data-rt]")] + .some((node) => /wall/i.test(node.textContent ?? "")); + if (wallControllerReady) { + globalThis.snWall.show("wall"); + await sleep(100); + } + const wallShown = + wallEl?.getAttribute("data-on") === "true" && + wallEl?.getAttribute("aria-hidden") === "false"; + if (wallControllerReady) { + globalThis.snWall.show("chat"); + await sleep(100); + } + const wallHidden = + wallEl?.getAttribute("data-on") === "false" && + wallEl?.getAttribute("aria-hidden") === "true"; + + const menuControllerReady = + typeof globalThis.openMenu === "function" && + typeof globalThis.closeMenu === "function"; + const attendeeMenuRole = document.body.getAttribute("data-role") ?? ""; + const visibleMenuItems = () => [...document.querySelectorAll("#menu-sheet button, #menu-sheet h4")] + .filter((node) => { + const style = getComputedStyle(node); + return style.display !== "none" && + style.visibility !== "hidden" && + node.getClientRects().length > 0; + }) + .map((node) => ({ + text: node.textContent?.replace(/\s+/g, " ").trim() ?? "", + onclick: node.getAttribute("onclick") ?? "", + })) + .filter((item) => item.text); + const checkNodeBenchHandoffUrl = (urlText) => { + try { + const url = new URL(urlText); + return url.origin === "https://nodebenchai.com" && + url.pathname === "/scratchnode-events" && + url.searchParams.get("source") === "scratchnode" && + url.searchParams.get("event") === "ai-infra-summit-2026" && + url.searchParams.get("room") === "ORBITAL" && + url.searchParams.get("continuation") === "private-notes" && + url.searchParams.get("publicArtifact") === "event-wiki" && + url.searchParams.get("return") === "https://scratchnode.live/e/ai-infra-summit-2026"; + } catch { + return false; + } + }; + const checkNodeBenchSignInUrl = (urlText, expectedReturn) => { + try { + const url = new URL(urlText); + return url.origin === "https://nodebenchai.com" && + url.pathname === "/sign-in" && + url.searchParams.get("intent") === "save-private-notes" && + url.searchParams.get("return") === expectedReturn; + } catch { + return false; + } + }; + let attendeeMenuItems = []; + let attendeeMenuText = ""; + if (menuControllerReady) { + globalThis.openMenu(); + await sleep(100); + attendeeMenuItems = visibleMenuItems(); + attendeeMenuText = attendeeMenuItems.map((item) => item.text).join(" | "); + globalThis.closeMenu(); + await sleep(50); + } + const attendeeMenuHasWiki = /Public wiki/i.test(attendeeMenuText); + const attendeeMenuHasShare = /Share/i.test(attendeeMenuText); + const attendeeMenuHasNodeBench = /Continue in NodeBench/i.test(attendeeMenuText); + const attendeeMenuHidesHostConsole = !/Host console/i.test(attendeeMenuText); + const attendeeMenuNodeBenchItem = attendeeMenuItems.find((item) => /Continue in NodeBench/i.test(item.text)); + const attendeeMenuNodeBenchCallsHandoff = + !!attendeeMenuNodeBenchItem && + /openNodeBenchPrivateHandoff\(\)/i.test(attendeeMenuNodeBenchItem.onclick); + const nodeBenchHandoffUrl = typeof globalThis.buildNodeBenchEventPrivateUrl === "function" + ? globalThis.buildNodeBenchEventPrivateUrl() + : ""; + const nodeBenchSignInUrl = typeof globalThis.buildNodeBenchSignInUrl === "function" + ? globalThis.buildNodeBenchSignInUrl(nodeBenchHandoffUrl) + : ""; + const nodeBenchHandoffUrlOk = checkNodeBenchHandoffUrl(nodeBenchHandoffUrl); + const nodeBenchSignInUrlOk = checkNodeBenchSignInUrl(nodeBenchSignInUrl, nodeBenchHandoffUrl); + + return { + functions, + wikiTitle, + wikiText: wikiText.slice(0, 160), + peopleTitle, + peopleText: peopleText.slice(0, 160), + shareTitle, + shareUrl, + shareCopyButtonText, + shareText: shareText.slice(0, 160), + shareHasPublicEventUrl, + shareHasCopyAction, + shareHasRoomCode, + shareHasMobileQrPrompt, + shareQrImage, + shareQrTargetsRoom, + shareHasSocialActions, + copyStubReady, + shareCopyWritesEventUrl, + roomCodeCopyWritesJoinContext, + notesText: notesText.slice(0, 160), + liveAssistControllerReady, + liveAssistClosedBefore, + liveAssistOpened, + liveAssistToggleOn, + liveAssistCueRendered, + liveAssistCueLeakedToFeed, + liveAssistClosedAfter, + eventModeControllerReady, + initialEventOk, + workModeOk, + sensitiveModeOk, + eventModeReturnedOk, + eventModeCycleOk, + eventModeResetOk, + eventModeSteps, + captureLevelControllerReady, + capturePickerOpened, + captureLevelOneOk, + captureLevelTwoBlocked, + captureLevelResetOk, + beforePrivate, + afterPrivate, + wallControllerReady, + wallTabVisible, + wallShown, + wallHidden, + menuControllerReady, + attendeeMenuRole, + attendeeMenuText: attendeeMenuText.slice(0, 180), + attendeeMenuNodeBenchItem, + attendeeMenuHasWiki, + attendeeMenuHasShare, + attendeeMenuHasNodeBench, + attendeeMenuHidesHostConsole, + attendeeMenuNodeBenchCallsHandoff, + nodeBenchHandoffUrl, + nodeBenchSignInUrl, + nodeBenchHandoffUrlOk, + nodeBenchSignInUrlOk, + }; + }); + const ok = + data.functions.length >= 12 && + /Wiki/i.test(data.wikiTitle) && + /People/i.test(data.peopleTitle) && + /Share|Invite/i.test(data.shareTitle) && + data.shareHasPublicEventUrl && + data.shareHasCopyAction && + data.shareHasRoomCode && + data.shareHasMobileQrPrompt && + data.shareQrTargetsRoom && + data.shareHasSocialActions && + data.copyStubReady && + data.shareCopyWritesEventUrl && + data.roomCodeCopyWritesJoinContext && + /private|notes/i.test(data.notesText) && + data.liveAssistControllerReady && + data.liveAssistClosedBefore && + data.liveAssistOpened && + data.liveAssistToggleOn && + data.liveAssistCueRendered && + !data.liveAssistCueLeakedToFeed && + data.liveAssistClosedAfter && + data.eventModeControllerReady && + data.eventModeCycleOk && + data.eventModeResetOk && + data.captureLevelControllerReady && + data.capturePickerOpened && + data.captureLevelOneOk && + data.captureLevelTwoBlocked && + data.captureLevelResetOk && + data.beforePrivate !== data.afterPrivate && + data.wallControllerReady && + data.wallTabVisible && + data.wallShown && + data.wallHidden && + data.menuControllerReady && + data.attendeeMenuRole === "attendee" && + data.attendeeMenuHasWiki && + data.attendeeMenuHasShare && + data.attendeeMenuHasNodeBench && + data.attendeeMenuHidesHostConsole && + data.attendeeMenuNodeBenchCallsHandoff && + data.nodeBenchHandoffUrlOk && + data.nodeBenchSignInUrlOk; + return { + ok, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("scratchnode demo route interactive", "https://scratchnode.live/demo_ver1?demoSpeed=instant", async (page) => { + await page.waitForFunction(() => document.body.getAttribute("data-page-mode") === "demo", null, { timeout: 15_000 }); + await page.waitForTimeout(1000); + const data = await page.evaluate(() => ({ + pageMode: document.body.getAttribute("data-page-mode"), + fullDemoAllowed: globalThis.shouldRunScratchNodeFullDemo?.(), + demoLogLength: Array.isArray(globalThis._demo_log) ? globalThis._demo_log.length : 0, + buttonCount: document.querySelectorAll("button").length, + })); + return { + ok: data.pageMode === "demo" && data.fullDemoAllowed === true && data.buttonCount > 0, + detail: JSON.stringify(data), + }; + }); + + await pageCheck("nodebench apex interactive", "https://nodebenchai.com/", async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + const data = await page.evaluate(() => ({ + title: document.title, + hasRoot: !!document.querySelector("#root"), + bodyText: (document.body.textContent ?? "").slice(0, 300), + })); + return { + ok: /NodeBench/i.test(data.title) || data.hasRoot, + detail: `title=${JSON.stringify(data.title)}, hasRoot=${data.hasRoot}`, + }; + }); + + await pageCheck("nodebench scratchnode-events interactive", "https://nodebenchai.com/scratchnode-events", async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + await page.waitForTimeout(1200); + const data = await page.evaluate(() => ({ + title: document.title, + hasRoot: !!document.querySelector("#root"), + text: (document.body.textContent ?? "").slice(0, 800), + scratchnodeActions: [...document.querySelectorAll("a,button")] + .map((el) => ({ + text: el.textContent?.trim() ?? "", + href: el instanceof HTMLAnchorElement ? el.href : "", + })) + .filter((item) => /ScratchNode|event|open|join/i.test(`${item.text} ${item.href}`)) + .slice(0, 12), + })); + return { + ok: data.hasRoot && /ScratchNode|events|NodeBench/i.test(data.text + data.title) && data.scratchnodeActions.length > 0, + detail: `title=${JSON.stringify(data.title)}, hasRoot=${data.hasRoot}, actions=${data.scratchnodeActions.length}`, + }; + }); + + await pageCheck("nodebench scratchnode-events handoff empty-state contract", "https://nodebenchai.com/scratchnode-events", async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + await page.waitForTimeout(1200); + const data = await page.evaluate(() => { + const text = (document.body.textContent ?? "").replace(/\s+/g, " ").trim(); + const scratchnodeLinks = [...document.querySelectorAll("a")] + .map((link) => ({ + text: link.textContent?.replace(/\s+/g, " ").trim() ?? "", + href: link.href, + })) + .filter((link) => /scratchnode\.live/i.test(link.href)); + return { + title: document.title, + hasRoot: !!document.querySelector("#root"), + hasHandoffTitle: /ScratchNode\s*(?:->|→)\s*NodeBench/i.test(text), + hasNoSessionState: /No ScratchNode session/i.test(text), + hasPrivateNotesContinuation: /private notes will appear/i.test(text), + scratchnodeLinks, + }; + }); + const ok = + /NodeBench/i.test(data.title) && + data.hasRoot && + data.hasHandoffTitle && + data.hasNoSessionState && + data.hasPrivateNotesContinuation && + data.scratchnodeLinks.some((link) => link.href === "https://scratchnode.live/" || link.href.startsWith("https://scratchnode.live/?")); + return { + ok, + detail: JSON.stringify({ + title: data.title, + hasRoot: data.hasRoot, + hasHandoffTitle: data.hasHandoffTitle, + hasNoSessionState: data.hasNoSessionState, + hasPrivateNotesContinuation: data.hasPrivateNotesContinuation, + scratchnodeLinks: data.scratchnodeLinks.slice(0, 3), + }), + }; + }); + + // Stable runtime proof for the ScratchNode -> NodeBench public wiki bridge: + // an unpublished slug must still resolve to the dedicated bridge surface with + // an honest empty state and a room-specific return link, never a 404 or + // cockpit fallback. Published rendering stays covered by the component test. + await pageCheck( + "nodebench scratchnode wiki bridge empty-state contract", + "https://nodebenchai.com/events/not-published/wiki?source=scratchnode&room=ORBITAL", + async (page) => { + await page.waitForSelector("body", { timeout: 15_000 }); + await page.waitForFunction( + () => + !!document.querySelector('[data-testid="scratchnode-wiki-bridge-empty"]') || + !!document.querySelector('[data-testid="scratchnode-wiki-bridge-body"]'), + null, + { timeout: 12_000 }, + ).catch(() => undefined); + const data = await page.evaluate(() => { + const text = (document.body.textContent ?? "").replace(/\s+/g, " ").trim(); + const scratchnodeLinks = [...document.querySelectorAll("a")] + .map((link) => ({ + text: link.textContent?.replace(/\s+/g, " ").trim() ?? "", + href: link.href, + })) + .filter((link) => /scratchnode\.live/i.test(link.href)); + return { + title: document.title, + hasRoot: !!document.querySelector("#root"), + hasBridgeShell: !!document.querySelector('[data-testid="scratchnode-wiki-bridge"]'), + hasEmptyState: !!document.querySelector('[data-testid="scratchnode-wiki-bridge-empty"]'), + hasRecapBody: !!document.querySelector('[data-testid="scratchnode-wiki-bridge-body"]'), + hasHonestEmptyCopy: /has(?:n['\u2019]t| not) published its wiki yet/i.test(text), + hasFake404: /404|not found|page not found/i.test(text), + scratchnodeLinks, + }; + }); + const ok = + /NodeBench/i.test(data.title) && + data.hasRoot && + data.hasBridgeShell && + data.hasEmptyState && + !data.hasRecapBody && + data.hasHonestEmptyCopy && + !data.hasFake404 && + data.scratchnodeLinks.some((link) => link.href === "https://scratchnode.live/e/orbital"); + return { + ok, + detail: JSON.stringify({ + title: data.title, + hasRoot: data.hasRoot, + hasBridgeShell: data.hasBridgeShell, + hasEmptyState: data.hasEmptyState, + hasRecapBody: data.hasRecapBody, + hasHonestEmptyCopy: data.hasHonestEmptyCopy, + hasFake404: data.hasFake404, + scratchnodeLinks: data.scratchnodeLinks.slice(0, 3), + }), + }; + }, + ); + + await browser.close(); +} + +function summarize() { + const requiredStaticFailures = staticChecks.filter((check) => !check.ok && !check.optional); + const blockerFindings = findings.filter((finding) => finding.severity === "blocker"); + const warnFindings = findings.filter((finding) => finding.severity === "warn"); + const liveFailures = liveChecks.filter((check) => !check.ok && !check.optional); + const interactiveFailures = interactiveChecks.filter((check) => !check.ok && !check.optional); + const remoteProbeInfra = summarizeRemoteProbeInfra({ liveFailures, interactiveFailures }); + const effectiveLiveFailures = remoteProbeInfra.networkAccessDenied ? [] : liveFailures; + const effectiveInteractiveFailures = remoteProbeInfra.networkAccessDenied ? [] : interactiveFailures; + const passed = + requiredStaticFailures.length === 0 && + blockerFindings.length === 0 && + effectiveLiveFailures.length === 0 && + effectiveInteractiveFailures.length === 0 && + (!shouldFailOnWarn || warnFindings.length === 0); + + return { + passed, + staticPassed: requiredStaticFailures.length === 0 && blockerFindings.length === 0, + livePassed: effectiveLiveFailures.length === 0, + interactivePassed: effectiveInteractiveFailures.length === 0, + requiredStaticFailures: requiredStaticFailures.length, + blockers: blockerFindings.length, + warnings: warnFindings.length, + autoSafeFindings: findings.filter((finding) => finding.safety === "auto").length, + humanGatedFindings: findings.filter((finding) => finding.safety === "human-gated").length, + liveFailures: effectiveLiveFailures.length, + interactiveFailures: effectiveInteractiveFailures.length, + rawLiveFailures: liveFailures.length, + rawInteractiveFailures: interactiveFailures.length, + remoteProbeInfra, + staticChecks: staticChecks.length, + liveChecks: liveChecks.length, + interactiveChecks: interactiveChecks.length, + }; +} + +async function main() { + checkRequiredFile(files.homeV5); + checkRequiredFile(files.vercel); + checkRequiredFile(files.scratchnodeConfig); + scanHomeV5(); + scanBackendContracts(); + scanPublicRepoReadiness(); + scanGoalAutomationReadiness(); + + if (shouldRunLive) await runLiveChecks(); + if (shouldRunInteractive) await runInteractiveChecks(); + + const report = { + generatedAt: new Date().toISOString(), + repo: repoRoot, + modes: { + static: true, + live: shouldRunLive, + interactive: shouldRunInteractive, + failOnWarn: shouldFailOnWarn, + }, + summary: summarize(), + findings, + staticChecks, + liveChecks, + interactiveChecks, + }; + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`); + + if (shouldPrintJson) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log( + `ScratchNode launch scan: ${report.summary.passed ? "PASS" : "FAIL"} ` + + `(blockers=${report.summary.blockers}, warnings=${report.summary.warnings}, ` + + `liveFailures=${report.summary.liveFailures}, interactiveFailures=${report.summary.interactiveFailures})`, + ); + console.log(`Report: ${outPath}`); + if (report.summary.remoteProbeInfra?.networkAccessDenied) { + console.log(`- [info/auto] remote probes suppressed: ${report.summary.remoteProbeInfra.reason}`); + } + for (const finding of findings.slice(0, 12)) { + const where = finding.line ? `${finding.path}:${finding.line}` : finding.path; + console.log(`- [${finding.severity}/${finding.safety}] ${where} ${finding.title}`); + } + if (findings.length > 12) { + console.log(`- ... ${findings.length - 12} more finding(s) in report`); + } + } + + if (!report.summary.passed) process.exitCode = 1; +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/App.tsx b/src/App.tsx index d155283e1..b529a1d95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,6 +80,16 @@ const ScratchnodeWikiBridge = lazy(() => default: m.ScratchnodeWikiBridge, })), ); +// /events/:slug/private — the ScratchNode -> NodeBench PRIVATE-NOTES bridge +// (roadmap item #4). Redeems an opaque, event-scoped, read-only handoff token +// from `?token=` and renders the bound session's private notes inside NodeBench. +// Mounted ABOVE /events/:eventId (same reason as /wiki) so the trailing +// /private segment is captured before the single-segment matcher. +const ScratchnodePrivateBridge = lazy(() => + import("@/features/events/views/ScratchnodePrivateBridge").then((m) => ({ + default: m.ScratchnodePrivateBridge, + })), +); // My Wiki — Phase 1 routes. See docs/architecture/ME_AGENT_DESIGN.md const WikiLandingRoute = lazy(() => import("@/features/me/components/wiki/WikiLandingRoute")); const WikiPageDetailRoute = lazy(() => import("@/features/me/components/wiki/WikiPageDetailRoute")); @@ -495,6 +505,32 @@ function App() { ); } + // /events/:slug/private — ScratchNode → NodeBench PRIVATE-NOTES bridge + // (roadmap #4). Redeems the opaque `?token=` handoff token and renders the + // bound session's private notes read-only. MUST also come before the + // single-segment /events/:eventId matcher (same trailing-segment reason as + // /wiki). The token is read here and passed down — never logged. + const eventPrivateRouteMatch = location.pathname.match(/^\/events\/([^/]+)\/private\/?$/); + if (eventPrivateRouteMatch) { + const slug = decodeURIComponent(eventPrivateRouteMatch[1] ?? ""); + const params = new URLSearchParams(location.search); + return ( + + + }> +
+ +
+
+
+
+ ); + } + const eventsRouteMatch = location.pathname.match(/^\/events\/([^/]+)\/?$/); if (eventsRouteMatch) { const eventId = decodeURIComponent(eventsRouteMatch[1] ?? ""); diff --git a/src/features/events/views/ScratchnodePrivateBridge.test.tsx b/src/features/events/views/ScratchnodePrivateBridge.test.tsx new file mode 100644 index 000000000..875b56cff --- /dev/null +++ b/src/features/events/views/ScratchnodePrivateBridge.test.tsx @@ -0,0 +1,177 @@ +/** + * Scenario tests for the ScratchNode → NodeBench PRIVATE-NOTES bridge surface. + * Persona: a guest who tapped "Continue in NodeBench" from a ScratchNode room and + * landed on /events//private?token=. + * + * Verifies the surface redeems the token EXACTLY once, renders the bound notes + * read-only, frames the sign-in conversion, and stays HONEST + FAIL-CLOSED on + * every denial (invalid / expired / used / missing token) — never a fabricated + * note, never the token or session id in the DOM. + */ +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const consumeMock = vi.fn(); +vi.mock("convex/react", () => ({ + useMutation: () => consumeMock, +})); + +import { ScratchnodePrivateBridge } from "./ScratchnodePrivateBridge"; + +const RESULT = { + eventName: "Rooftop Launch Party", + eventSlug: "rooftop-launch", + roomCode: "ROOFTOP", + scope: "private_notes_read" as const, + noteCount: 1, + notes: [ + { + noteId: "userNotes:1", + title: "My takeaways", + bodyHtml: "

PRIVATE_NOTE_BODY

", + tags: ["mcp"], + pinned: true, + isAsk: false, + createdAt: 1770000000000, + updatedAt: 1770000000000, + }, + ], + _truncated: false, +}; + +function convexError(code: string) { + const err = new Error(code) as Error & { data?: { code: string } }; + err.data = { code }; + return err; +} + +afterEach(() => { + cleanup(); + consumeMock.mockReset(); +}); + +describe("ScratchnodePrivateBridge", () => { + it("redeems the token once and renders the bound private notes read-only", async () => { + consumeMock.mockResolvedValue(RESULT); + render(); + + await waitFor(() => + expect(screen.getByTestId("scratchnode-private-bridge-note-body")).toHaveTextContent( + "PRIVATE_NOTE_BODY", + ), + ); + // Conversion affordance points at sign-in (keep these notes). + const cta = screen.getByTestId("scratchnode-private-bridge-cta-signin"); + expect(cta).toHaveAttribute("href", "/sign-in?intent=save-private-notes"); + + // Consume called EXACTLY once with ONLY the token (no owner key). + expect(consumeMock).toHaveBeenCalledTimes(1); + expect(consumeMock).toHaveBeenCalledWith({ token: "opaque-token-abcdefghijklmno" }); + + // The token must NEVER appear in the rendered DOM. + expect(document.body.innerHTML).not.toContain("opaque-token-abcdefghijklmno"); + }); + + it("prefers the redeemed room code over a stale query room when building the ScratchNode return link", async () => { + consumeMock.mockResolvedValue(RESULT); + render( + , + ); + + await waitFor(() => + expect(screen.getByTestId("scratchnode-private-bridge-note-body")).toHaveTextContent( + "PRIVATE_NOTE_BODY", + ), + ); + + expect(screen.getByText(/Back to ScratchNode/i)).toHaveAttribute( + "href", + "https://scratchnode.live/e/rooftop", + ); + }); + + it("shows a loading state while redeeming (no premature empty/error)", () => { + // Never resolves — stays in the redeeming phase. + consumeMock.mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByTestId("scratchnode-private-bridge-loading")).toBeInTheDocument(); + expect(screen.queryByTestId("scratchnode-private-bridge-error")).toBeNull(); + expect(screen.queryByTestId("scratchnode-private-bridge-note")).toBeNull(); + }); + + it("FAIL-CLOSED: an expired token shows an honest expired state, never a note", async () => { + consumeMock.mockRejectedValue(convexError("token_expired")); + render(); + + await waitFor(() => { + const err = screen.getByTestId("scratchnode-private-bridge-error"); + expect(err).toHaveAttribute("data-error-code", "token_expired"); + expect(err).toHaveTextContent(/expired/i); + }); + expect(screen.queryByTestId("scratchnode-private-bridge-note")).toBeNull(); + }); + + it("FAIL-CLOSED: an invalid/tampered token shows the invalid state (no fabricated notes)", async () => { + consumeMock.mockRejectedValue(convexError("invalid_token")); + render(); + + await waitFor(() => { + const err = screen.getByTestId("scratchnode-private-bridge-error"); + expect(err).toHaveAttribute("data-error-code", "invalid_token"); + }); + expect(screen.queryByTestId("scratchnode-private-bridge-note-body")).toBeNull(); + }); + + it("FAIL-CLOSED: a used-up token shows the used state", async () => { + consumeMock.mockRejectedValue(convexError("token_used")); + render(); + + await waitFor(() => + expect(screen.getByTestId("scratchnode-private-bridge-error")).toHaveAttribute( + "data-error-code", + "token_used", + ), + ); + }); + + it("shows a 'missing token' state and NEVER calls consume when no token is present", () => { + render(); + + expect(screen.getByTestId("scratchnode-private-bridge-no-token")).toBeInTheDocument(); + expect(consumeMock).not.toHaveBeenCalled(); + }); + + it("renders an honest empty state when the room has zero private notes (not an error)", async () => { + consumeMock.mockResolvedValue({ ...RESULT, noteCount: 0, notes: [] }); + render(); + + await waitFor(() => + expect(screen.getByTestId("scratchnode-private-bridge-empty")).toBeInTheDocument(), + ); + expect(screen.queryByTestId("scratchnode-private-bridge-error")).toBeNull(); + }); + + it("SANITIZES note bodyHtml — a script/onerror payload never reaches the DOM", async () => { + consumeMock.mockResolvedValue({ + ...RESULT, + notes: [ + { + ...RESULT.notes[0], + bodyHtml: '

safe

', + }, + ], + }); + render(); + + await waitFor(() => screen.getByTestId("scratchnode-private-bridge-note-body")); + const body = screen.getByTestId("scratchnode-private-bridge-note-body"); + expect(body.querySelector("script")).toBeNull(); + expect(body.innerHTML).not.toContain("onerror"); + expect(body).toHaveTextContent("safe"); + }); +}); diff --git a/src/features/events/views/ScratchnodePrivateBridge.tsx b/src/features/events/views/ScratchnodePrivateBridge.tsx new file mode 100644 index 000000000..f6a4cec1a --- /dev/null +++ b/src/features/events/views/ScratchnodePrivateBridge.tsx @@ -0,0 +1,382 @@ +/** + * ScratchnodePrivateBridge — the REAL cross-domain ScratchNode → NodeBench + * PRIVATE-NOTES receiving surface for `/events/:slug/private?token=`. + * + * THE PROBLEM (roadmap item #4, security-critical capstone) + * A guest's private notes on scratchnode.live are owner-keyed by + * `sn_session_id` in localStorage, which is ORIGIN-PARTITIONED — so this app + * (nodebenchai.com) physically cannot read it. ScratchNode mints a SERVER-ONLY + * opaque token bound to {event, session}, and ONLY that token travels in the + * URL. This surface redeems it. + * + * WHAT IT DOES + * Reads `?token=` from the URL, calls the `scratchnodeHandoff:consumeEventHandoffToken` + * MUTATION exactly once on mount (it's a mutation, not a query, because consume + * burns a use), and renders the bound session's private notes READ-ONLY inside + * the NodeBench shell with a "sign in to keep these" conversion affordance. + * + * HONESTY / SECURITY (non-negotiable) + * - FAIL-CLOSED: unknown / expired / used-up / wrong-scope token → a real, + * honest state (never a fabricated note). Each maps to a distinct message. + * - The token and the session id are NEVER rendered and NEVER logged. The + * token is consumed once, then dropped from component state. + * - bodyHtml is DOMPurify-sanitized before render (defense in depth — these + * notes are the user's OWN rich text, but the main app must never eval it). + * - The `?token=` is stripped from the address bar after redemption (history + * hygiene) so a leaked URL is even less useful. + * + * Prior art: mirrors src/features/events/views/ScratchnodeWikiBridge.tsx (the + * public wiki receiving surface) — same rd-* shell, same conversion frame. + */ + +import { useEffect, useRef, useState } from "react"; +import { useMutation } from "convex/react"; +import DOMPurify from "dompurify"; +import { api } from "../../../../convex/_generated/api"; + +type HandoffNote = { + noteId: string; + title: string; + bodyHtml: string; + tags: string[]; + pinned: boolean; + isAsk: boolean; + createdAt: number; + updatedAt: number; +}; + +type ConsumeResult = { + eventName: string; + eventSlug: string; + roomCode: string; + scope: "private_notes_read"; + noteCount: number; + notes: HandoffNote[]; + _truncated: boolean; +}; + +const SCRATCHNODE_ORIGIN = "https://scratchnode.live"; + +// Map the backend's fail-closed ConvexError codes to honest, human copy. +// Unknown codes collapse to the generic invalid message — never a fake success. +const ERROR_COPY: Record = { + invalid_token: { + title: "This continuation link is invalid", + detail: + "The link may be incomplete, already used, or from a different room. Re-open it from ScratchNode to try again.", + }, + token_expired: { + title: "This continuation link has expired", + detail: + "Links are short-lived for your security. Head back to the room on ScratchNode and tap “Continue in NodeBench” again.", + }, + token_used: { + title: "This continuation link was already used", + detail: + "For your security each link is single-use. Re-open it from ScratchNode to bring your notes over again.", + }, + invalid_scope: { + title: "This link doesn’t grant access to private notes", + detail: "Re-open the handoff from the ScratchNode room to continue your notes.", + }, + event_not_found: { + title: "That event no longer exists", + detail: "The room this link points to is gone. Nothing to bring over.", + }, +}; + +const GENERIC_ERROR = { + title: "We couldn’t open your notes", + detail: + "Something went wrong redeeming this link. Re-open it from ScratchNode, or sign in to NodeBench to keep your notes.", +}; + +function readErrorCode(err: unknown): string { + // Convex serializes ConvexError as `{ data: { code } }`. Be defensive — never + // surface a raw error string (which could echo input) to the user. + const data = (err as { data?: { code?: unknown } } | undefined)?.data; + const code = data && typeof data.code === "string" ? data.code : ""; + return code; +} + +function formatDate(ms: number): string { + try { + return new Date(ms).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return ""; + } +} + +interface Props { + slug: string; + /** The opaque handoff token from `?token=`. Consumed once, never rendered. */ + token: string | null; + /** From `?room=` — lets "Open in ScratchNode" deep-link the room. */ + roomCode?: string | null; +} + +type Phase = "redeeming" | "ready" | "error" | "no_token"; + +export function ScratchnodePrivateBridge({ slug, token, roomCode }: Props) { + const consume = useMutation( + // `as any` — consumeEventHandoffToken may not be in _generated/api.d.ts on + // this branch yet; codegen runs at deploy (same pattern as the wiki bridge). + (api as any).scratchnodeHandoff.consumeEventHandoffToken, + ); + + const [phase, setPhase] = useState(token ? "redeeming" : "no_token"); + const [result, setResult] = useState(null); + const [errorCode, setErrorCode] = useState(""); + // Guard against React 18 StrictMode double-invoke + re-renders: consume EXACTLY + // once. A second call would needlessly burn another use of the (low-use) token. + const redeemedRef = useRef(false); + + useEffect(() => { + if (!token) { + setPhase("no_token"); + return; + } + if (redeemedRef.current) return; + redeemedRef.current = true; + + let cancelled = false; + (async () => { + try { + const res = (await consume({ token })) as ConsumeResult; + if (cancelled) return; + setResult(res); + setPhase("ready"); + } catch (err) { + if (cancelled) return; + setErrorCode(readErrorCode(err)); + setPhase("error"); + } finally { + // History hygiene: drop the token from the address bar regardless of + // outcome so it can't be re-shared / re-read from history. Best-effort. + try { + const url = new URL(window.location.href); + if (url.searchParams.has("token")) { + url.searchParams.delete("token"); + window.history.replaceState({}, "", url.toString()); + } + } catch { + /* no-op — replaceState can fail in exotic embeds; not fatal */ + } + } + })(); + + return () => { + cancelled = true; + }; + // consume identity is stable; token drives the single redemption. + }, [token, consume]); + + // Once the token redeems, trust the backend-bound room code over the inbound + // query param so a stale/tampered `?room=` cannot misdirect the return link. + const roomUrl = `${SCRATCHNODE_ORIGIN}/e/${encodeURIComponent( + String(result?.roomCode || roomCode || slug).toLowerCase(), + )}`; + + return ( +
+
+
ScratchNode → NodeBench
+

+ {result?.eventName ? `${result.eventName} — your notes` : "Your private notes"} +

+

+ Private notes you took in the room, brought into NodeBench. Only you can see these. +

+
+ + {phase === "redeeming" && ( +
+ Bringing your private notes over… +
+ )} + + {phase === "no_token" && ( +
+

+ This link is missing its continuation token. +

+

+ Open “Continue in NodeBench” from the ScratchNode room to bring your private notes over. +

+ + Open in ScratchNode → + +
+ )} + + {phase === "error" && (() => { + const copy = ERROR_COPY[errorCode] ?? GENERIC_ERROR; + return ( +
+

+ {copy.title} +

+

+ {copy.detail} +

+ + Open in ScratchNode → + +
+ ); + })()} + + {phase === "ready" && result && ( + <> + {result.notes.length === 0 ? ( +
+

+ No private notes from this room yet. +

+

+ Notes you take in the room show up here. Head back to keep going. +

+
+ ) : ( +
+
+ {[ + `${result.noteCount} private note${result.noteCount === 1 ? "" : "s"}`, + result.roomCode ? `code ${String(result.roomCode).toUpperCase()}` : "", + "read-only", + ] + .filter(Boolean) + .join(" · ")} + {result._truncated ? " · showing the first 200" : ""} +
+ {result.notes.map((note) => ( + + ))} +
+ )} + + {/* The conversion moment — sign in to keep these private notes. */} +
+

+ Sign in to keep these. +

+

+ Right now these live only on the room’s session. Sign in to save them to your NodeBench + workspace — searchable, linked to entities, and yours across every event. +

+ +
+ + )} +
+ ); +} + +function NoteCard({ note }: { note: HandoffNote }) { + // Sanitize the owner's own rich text before injecting into the main app DOM. + const safe = DOMPurify.sanitize(note.bodyHtml || ""); + return ( +
+
+

+ {note.pinned ? "📌 " : ""} + {note.title || "Untitled"} +

+ + {formatDate(note.updatedAt)} + +
+ {/* bodyHtml is the owner's own note text, DOMPurify-sanitized here. */} +
+ {note.tags.length > 0 && ( +
+ {note.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ ); +} + +export default ScratchnodePrivateBridge; diff --git a/src/features/events/views/ScratchnodeWikiBridge.test.tsx b/src/features/events/views/ScratchnodeWikiBridge.test.tsx index 097f494e7..09257129f 100644 --- a/src/features/events/views/ScratchnodeWikiBridge.test.tsx +++ b/src/features/events/views/ScratchnodeWikiBridge.test.tsx @@ -2,10 +2,10 @@ * Scenario tests for the ScratchNode -> NodeBench bridge receiving surface. * Persona: a guest who clicked "Continue in NodeBench" from a ScratchNode wiki. * Verifies the route renders the public recap, frames the conversion, stays - * honest on unpublished/loading, and SANITIZES the wiki body (XSS defense). + * honest on unpublished/loading, and sanitizes the wiki body (XSS defense). */ -import { render, screen, cleanup } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; const useQueryMock = vi.fn(); vi.mock("convex/react", () => ({ @@ -36,32 +36,49 @@ describe("ScratchnodeWikiBridge", () => { render(); expect(screen.getByTestId("scratchnode-wiki-bridge-body")).toHaveTextContent("PUBLIC_RECAP_BODY"); - // Conversion CTA points into the NodeBench app (a real, working route). const cta = screen.getByTestId("scratchnode-wiki-bridge-cta-nodebench"); expect(cta).toHaveAttribute("href", "/"); - // Reverse paths back to ScratchNode are present and well-formed. expect(screen.getByText("View the public wiki")).toHaveAttribute( "href", "https://scratchnode.live/wiki/rooftop-launch", ); - expect(screen.getByText("Open in ScratchNode →")).toHaveAttribute( + expect(screen.getByText(/Open in ScratchNode/)).toHaveAttribute( "href", "https://scratchnode.live/e/rooftop", ); }); - it("shows an honest empty state for an unpublished/unknown room (never a fake recap)", () => { + it("prefers the published wiki room code over a stale query room when building the return link", () => { + useQueryMock.mockReturnValue(WIKI); + render(); + + expect(screen.getAllByText(/Open in ScratchNode/)[0]).toHaveAttribute( + "href", + "https://scratchnode.live/e/rooftop", + ); + }); + + it("shows an honest empty state for an unpublished or unknown room", () => { useQueryMock.mockReturnValue(null); render(); expect(screen.getByTestId("scratchnode-wiki-bridge-empty")).toHaveTextContent( - "hasn’t published its wiki yet", + /hasn.t published its wiki yet/i, ); - // No recap body is fabricated. expect(screen.queryByTestId("scratchnode-wiki-bridge-body")).toBeNull(); }); - it("shows a loading state while the query resolves (no premature empty/error)", () => { + it("uses the explicit room code for the ScratchNode return link when no wiki is published yet", () => { + useQueryMock.mockReturnValue(null); + render(); + + expect(screen.getByText(/Open in ScratchNode/)).toHaveAttribute( + "href", + "https://scratchnode.live/e/orbital", + ); + }); + + it("shows a loading state while the query resolves", () => { useQueryMock.mockReturnValue(undefined); render(); @@ -69,7 +86,7 @@ describe("ScratchnodeWikiBridge", () => { expect(screen.queryByTestId("scratchnode-wiki-bridge-empty")).toBeNull(); }); - it("SANITIZES the wiki body — script/handlers are stripped before render (XSS defense)", () => { + it("sanitizes the wiki body before render", () => { useQueryMock.mockReturnValue({ ...WIKI, bodyHtml: @@ -79,9 +96,35 @@ describe("ScratchnodeWikiBridge", () => { const body = screen.getByTestId("scratchnode-wiki-bridge-body"); expect(body).toHaveTextContent("KEEP_THIS"); - // The dangerous bits never reach the DOM. expect(container.querySelector("script")).toBeNull(); expect(body.innerHTML).not.toContain("onerror"); expect(body.innerHTML).not.toContain("window.__xss"); }); + + it("keeps public wiki bridge links visibility-safe and free of private handoff params", () => { + useQueryMock.mockReturnValue(WIKI); + render( + , + ); + + const publicWikiHref = screen.getByText("View the public wiki").getAttribute("href"); + const roomHref = screen.getByText(/Open in ScratchNode/).getAttribute("href"); + + expect(publicWikiHref).toBe("https://scratchnode.live/wiki/rooftop-launch"); + expect(roomHref).toBe("https://scratchnode.live/e/rooftop"); + + for (const href of [publicWikiHref, roomHref]) { + expect(href).not.toContain("token="); + expect(href).not.toContain("session="); + expect(href).not.toContain("source="); + expect(href).not.toContain("room="); + expect(href).not.toContain("continuation="); + expect(href).not.toContain("publicArtifact="); + expect(href).not.toContain("noteCount="); + } + }); }); diff --git a/src/features/events/views/ScratchnodeWikiBridge.tsx b/src/features/events/views/ScratchnodeWikiBridge.tsx index 78da76a46..ce7e6ac43 100644 --- a/src/features/events/views/ScratchnodeWikiBridge.tsx +++ b/src/features/events/views/ScratchnodeWikiBridge.tsx @@ -20,6 +20,11 @@ * * Prior art: pattern mirrors src/features/redesign/surfaces/ScratchnodeEventsSurface.tsx * (the existing read-only ScratchNode handoff surface). + * + * Ownership: this is the NodeBench-owned bridge/conversion surface, not the + * ScratchNode-owned public wiki SSR reader in `api/scratchnode-wiki.js`. The + * two may read the same public wiki query, but this route must not duplicate + * ScratchNode publishing/SSR ownership or accept private tokens. */ import { useMemo } from "react"; @@ -77,9 +82,10 @@ export function ScratchnodeWikiBridge({ slug, roomCode }: Props) { ); const publicWikiUrl = `${SCRATCHNODE_ORIGIN}/wiki/${encodeURIComponent(slug)}`; - const roomUrl = `${SCRATCHNODE_ORIGIN}/e/${encodeURIComponent( - String(roomCode || wiki?.roomCode || slug).toLowerCase(), - )}`; + // Once the published wiki loads, prefer the server-returned room code over the + // inbound query param so a stale/tampered `?room=` cannot misdirect the return link. + const roomJoinTarget = String(wiki?.roomCode || roomCode || slug).toLowerCase(); + const roomUrl = `${SCRATCHNODE_ORIGIN}/e/${encodeURIComponent(roomJoinTarget)}`; return (
{ ); }); + it("routes manual location-spot notes into the active event session without extra event keywords", () => { + const route = inferCaptureRoute({ + text: "Investor Lounge follow-up: ask Priya for the sponsor list after the panel.", + mode: "note", + }); + + expect(route.intent).toBe("create_followup"); + expect(route.target).toBe("active_event_session"); + expect(route.gate).toBe("auto_route"); + expect(route.followUps.some((item) => item.text.includes("Investor Lounge follow-up"))).toBe(true); + expect(route.ack).toContain("Saved to active event session"); + }); + + it("routes deeper self-directed event follow-ups with location anchors", () => { + const route = inferCaptureRoute({ + text: "Go deeper on Priya from Helio Labs at Panel Room A and prep next questions for the sponsor intro.", + mode: "task", + }); + + expect(route.intent).toBe("create_followup"); + expect(route.target).toBe("active_event_session"); + expect(route.gate).toBe("auto_route"); + expect(route.entities.map((entity) => entity.name)).toEqual( + expect.arrayContaining(["Priya", "Helio Labs"]), + ); + expect(route.followUps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: "Deepen event follow-up across people, companies, topics, and anchors", + priority: "high", + }), + ]), + ); + }); + + it("treats afterparty location notes as event captures instead of unassigned notes", () => { + const route = inferCaptureRoute({ + text: "Afterparty notes: founder intros moved to the rooftop bar.", + mode: "note", + }); + + expect(route.intent).toBe("capture_field_note"); + expect(route.target).toBe("active_event_session"); + expect(route.needsConfirmation).toBe(false); + }); + it("keeps uncertain low-signal captures in review", () => { const route = inferCaptureRoute({ text: "interesting thing from last week", diff --git a/src/features/product/lib/captureRouter.ts b/src/features/product/lib/captureRouter.ts index 2be272adf..eb1b87692 100644 --- a/src/features/product/lib/captureRouter.ts +++ b/src/features/product/lib/captureRouter.ts @@ -76,8 +76,10 @@ const QUESTION_START = /^(ask|who|what|when|where|why|how|which|compare|research const EXPLORE_REQUEST_MARKERS = /\b(need|create|build|produce|turn this into|cluster|compare|rank|verify|diligence|research|explore|workspace|map|brief|sources|cards|notebook)\b/i; const FIELD_NOTE_MARKERS = /\b(met|talked|spoke|coffee|demo day|conference|booth|voice memo|recorded|handwritten|whiteboard|screenshot|photo|notes?|lecture|pitch|customer call)\b/i; const FOLLOW_UP_MARKERS = /\b(follow up|follow-up|todo|remind|task|next step|ask them|email|intro|reply|schedule)\b/i; +const DEEPER_FOLLOW_UP_MARKERS = /\b(go deeper|deep dive|deeper follow-up|deeper follow up|deepen|research next|follow through)\b/i; const APPEND_MARKERS = /\b(add|attach|save|append|put this|log this)\b.*\b(report|brief|dossier|workspace|notebook)\b/i; const EVENT_MARKERS = /\b(demo day|conference|event|booth|lecture|whiteboard|pitch|summit|meetup)\b/i; +const LOCATION_SPOT_MARKERS = /\b(booth\s*\d+|lobby|panel\s+room\s+[a-z0-9]+|investor\s+lounge|afterparty)\b/i; const INBOX_MARKERS = /\b(recruiter|email|inbox|newsletter|invite|application|offer|rejected|job spec)\b/i; const REPORT_MARKERS = /\b(report|brief|dossier|market map|prd|memo|company|startup|competitor|vendor|paper|repo)\b/i; const COMPANY_SUFFIX = /\b(Inc|Labs|AI|Systems|Technologies|Tech|Health|Bio|Robotics|Capital|Ventures|Partners|Bank|University|Labs)\b/; @@ -161,14 +163,14 @@ function inferIntent( hasFiles: boolean, ): CaptureIntent { if (mode === "task") return "create_followup"; - if (FOLLOW_UP_MARKERS.test(text)) return "create_followup"; + if (FOLLOW_UP_MARKERS.test(text) || DEEPER_FOLLOW_UP_MARKERS.test(text)) return "create_followup"; if (APPEND_MARKERS.test(text)) return "append_to_report"; if (mode === "note") return "capture_field_note"; if (/^\s*ask\b/i.test(text)) return "ask_question"; if (mode === "ask" && EXPLORE_REQUEST_MARKERS.test(text) && (EVENT_MARKERS.test(text) || REPORT_MARKERS.test(text))) { return "expand_entity"; } - if (FIELD_NOTE_MARKERS.test(text) || hasFiles) return "capture_field_note"; + if (FIELD_NOTE_MARKERS.test(text) || LOCATION_SPOT_MARKERS.test(text) || hasFiles) return "capture_field_note"; if (QUESTION_START.test(text) || text.includes("?")) { return REPORT_MARKERS.test(text) ? "expand_entity" : "ask_question"; } @@ -183,11 +185,11 @@ function inferTarget( ): CaptureTarget { const context = activeContextLabel?.trim() ?? ""; const looksLikeEventContext = - EVENT_MARKERS.test(context) || /\b(demo|conference|event|summit|meetup)\b/i.test(context); + EVENT_MARKERS.test(context) || LOCATION_SPOT_MARKERS.test(context) || /\b(demo|conference|event|summit|meetup)\b/i.test(context); const looksLikeEventCapture = - /\b(?:met|talked to|spoke with)\s+[A-Z][A-Za-z.-]*(?:\s+[A-Z][A-Za-z.-]*)?\s+from\s+[A-Z]/i.test(text); + /\b(?:met|talked to|spoke with)\s+[A-Z][A-Za-z.-]*(?:\s+(?!from\b)[A-Z][A-Za-z.-]*)?\s+from\s+[A-Z]/i.test(text); - if (EVENT_MARKERS.test(text) || looksLikeEventContext || looksLikeEventCapture) { + if (EVENT_MARKERS.test(text) || LOCATION_SPOT_MARKERS.test(text) || looksLikeEventContext || looksLikeEventCapture) { return "active_event_session"; } if (INBOX_MARKERS.test(text)) return "inbox_item"; @@ -216,6 +218,7 @@ function scoreRoute(args: { if (args.followUps.length > 0) score += 0.08; if (args.target !== "unassigned_buffer") score += 0.08; if (args.target === "active_event_session" && args.entities.length >= 2) score += 0.04; + if (args.target === "active_event_session" && LOCATION_SPOT_MARKERS.test(args.text)) score += 0.32; if (args.intent === "ask_question" || args.intent === "expand_entity") score += 0.04; if (args.text.length > 240) score += 0.04; return Math.min(0.96, Number(score.toFixed(2))); @@ -253,6 +256,11 @@ function extractEntities( const metMatch = text.match(/\b(?:met|talked to|spoke with|coffee with)\s+([A-Z][A-Za-z.-]*(?:\s+(?!from\b)[A-Z][A-Za-z.-]*)?)/i); if (metMatch) add(metMatch[1], "person", 0.82); + const deeperFollowUpPersonMatch = text.match( + /\b(?:go deeper on|deep dive on|deepen|research next|follow through with)\s+([A-Z][A-Za-z.-]*(?:\s+(?!from\b|at\b|with\b|and\b)[A-Z][A-Za-z.-]*)?)/i, + ); + if (deeperFollowUpPersonMatch) add(deeperFollowUpPersonMatch[1], "person", 0.78); + const capitalized = text.match(/\b[A-Z][A-Za-z0-9&-]*(?:\s+[A-Z][A-Za-z0-9&-]*){0,3}\b/g) ?? []; for (const phrase of capitalized) { const first = phrase.split(/\s+/)[0]; @@ -310,12 +318,19 @@ function extractFollowUps( ): CaptureFollowUp[] { const followUps: CaptureFollowUp[] = []; const normalized = text.toLowerCase(); - if (FOLLOW_UP_MARKERS.test(text) || intent === "create_followup") { + const wantsDeeperFollowUp = DEEPER_FOLLOW_UP_MARKERS.test(text); + if (FOLLOW_UP_MARKERS.test(text) || wantsDeeperFollowUp || intent === "create_followup") { followUps.push({ text: text.replace(/^[-*]\s*/, "").slice(0, 160), priority: normalized.includes("urgent") || normalized.includes("tomorrow") ? "high" : "medium", }); } + if (wantsDeeperFollowUp && target === "active_event_session") { + followUps.push({ + text: "Deepen event follow-up across people, companies, topics, and anchors", + priority: "high", + }); + } if (normalized.includes("design partner") || normalized.includes("pilot")) { followUps.push({ text: "Ask about pilot criteria and design-partner timeline", diff --git a/src/features/workspace/lib/eventWorkspacePersistence.test.ts b/src/features/workspace/lib/eventWorkspacePersistence.test.ts index 08664a37c..78244a8f2 100644 --- a/src/features/workspace/lib/eventWorkspacePersistence.test.ts +++ b/src/features/workspace/lib/eventWorkspacePersistence.test.ts @@ -80,6 +80,28 @@ describe("eventWorkspacePersistence", () => { expect(args.capture.extractedClaimIds.length).toBe(args.claims.length); }); + it("persists manual location-spot captures into the event workspace follow-up lane", () => { + const input = "Investor Lounge follow-up: ask Priya for the sponsor list after the panel."; + const route = inferCaptureRoute({ + text: input, + mode: "note", + }); + const args = buildLiveCaptureArgs({ + workspaceId: "ai-infra-summit-2026", + input, + now: 1777068715905, + route, + }); + + expect(shouldPersistRouteToEventWorkspace(route)).toBe(true); + expect(args.capture.status).toBe("attached"); + expect(args.followUps.some((followUp) => followUp.action.includes("Investor Lounge follow-up"))).toBe(true); + expect(args.evidence[0]).toMatchObject({ + layer: "private_capture", + visibility: "private", + }); + }); + it("maps live Convex rows back into the workspace memory view model", () => { const mapped = mapLiveSnapshotToMemory({ entities: [ diff --git a/tests/e2e/live-smoke.spec.ts b/tests/e2e/live-smoke.spec.ts index 14dbcb542..48ff28c12 100644 --- a/tests/e2e/live-smoke.spec.ts +++ b/tests/e2e/live-smoke.spec.ts @@ -127,15 +127,15 @@ test.describe("live-smoke — Tier B hydrated-DOM verification", () => { const response = await page.goto(BASE_URL + "/workspace/w/acme-ai?tab=brief"); expect(response?.status()).toBe(200); // Chromeless shell — no cockpit rails, no top nav, no bottom tabbar. - // Assert the workspace mounted and kept the no-fixture-fallback contract. - await expect(page.getByRole("heading", { name: /Workspace draft/i })).toBeVisible({ + // Assert the standalone workspace mounted on the requested live artifact. + await expect(page.locator('[data-agent-contact="standalone-workspace-runtime"]')).toBeVisible({ timeout: 15_000, }); - // Brand-aware: the product lock docs say "Workspace" should appear in the - // chromeless header; this also proves the route didn't fall through to - // the cockpit layout. - await expect(page.getByText(/Workspace/i).first()).toBeVisible(); - await expect(page.getByText(/No fixture fallback/i)).toBeVisible(); + await expect(page.getByRole("heading", { name: /^acme-ai$/i })).toBeVisible(); + await expect(page.getByRole("tablist", { name: /Workspace tabs/i })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Brief", selected: true })).toBeVisible(); + await expect(page.getByText(/Detail pending - report shell active/i)).toBeVisible(); + await expect(page.getByText(/Live artifact/i).first()).toBeVisible(); }); test("console has no uncaught errors during landing load", async ({ page }) => { diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 0d7d061d0..89bea623b 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -32,6 +32,16 @@ async function fulfillScratchNodePage( constructor(url) { window.__snMockClientUrl = url; window.__snMockMutations = []; + window.__snMockMessages = []; + window.__snMockAnswers = []; + window.__snMockNotes = []; + window.__snMockAnchors = []; + window.__snMockActions = []; + window.__snMockPromotedAnswerIds = []; + window.__snMockPublishedWiki = null; + window.__snWikiPublished = false; + window.__snPublishWikiArgs = null; + window.__snMockSubscriptions = {}; } close() { window.__snMockClosed = true; } mutation(name, args) { @@ -56,7 +66,23 @@ async function fulfillScratchNodePage( err.data = { code: 'network_down' }; return Promise.reject(err); } - return Promise.resolve({ messageId: 'liveEventMessages:1' }); + const messageId = 'liveEventMessages:' + (window.__snMockMessages.length + 1); + const nextMessage = { + _id: messageId, + eventId: args.eventId, + sessionId: args.sessionId, + displayName: args.displayName, + text: args.text, + kind: args.kind, + replyToMessageId: args.replyToMessageId, + createdAt: 1770000000000 + window.__snMockMessages.length, + }; + window.__snMockMessages.push(nextMessage); + const notifyMessages = window.__snMockSubscriptions['events:getMessages']; + if (typeof notifyMessages === 'function') { + notifyMessages(window.__snMockMessages.slice()); + } + return Promise.resolve({ messageId }); } if (name === 'events:createEvent') { window.__snCreatedEventArgs = args; @@ -98,11 +124,59 @@ async function fulfillScratchNodePage( localStorage.setItem('__snEndedEventArgs', JSON.stringify(args)); return Promise.resolve({ ok: true, eventId: args.eventId, status: 'ended' }); } + if (name === 'events:suggestAnswerForFaq') { + return Promise.resolve({ ok: true, answerId: args.answerId }); + } + if (name === 'events:promoteAnswerToFaq') { + const promoted = Array.isArray(window.__snMockPromotedAnswerIds) + ? window.__snMockPromotedAnswerIds + : []; + if (!promoted.includes(args.answerId)) promoted.push(args.answerId); + window.__snMockPromotedAnswerIds = promoted; + return Promise.resolve({ ok: true, answerId: args.answerId }); + } if (name === 'events:publishWiki') { window.__snWikiPublished = true; window.__snPublishWikiArgs = args; localStorage.setItem('__snWikiPublishArgs', JSON.stringify(args)); - return Promise.resolve({ ok: true, version: 1, wikiId: 'liveEventWikiVersions:1' }); + const promotedIds = Array.isArray(window.__snMockPromotedAnswerIds) + ? window.__snMockPromotedAnswerIds + : []; + const promotedAnswers = (window.__snMockAnswers || []).filter((answer) => + promotedIds.includes(answer._id), + ); + const bodyHtml = + '' + + '

AI Infra Summit · Wiki

' + + promotedAnswers.map((answer, index) => + '
' + + '

' + answer.question + '

' + + '

' + answer.body + '

' + + '
' + answer.sourceCount + ' sources
' + + '
' + ).join('') + + '
🔒
Privacy: Your private notes never enter the wiki. Only public chat, /ask answers, and host-uploaded sources are compacted into this page.
'; + const finalBodyHtml = promotedAnswers.length + ? bodyHtml + : '

AI Infra Summit Wiki

Published public memory.

'; + window.__snMockPublishedWiki = { + eventId: args.eventId, + version: 1, + title: 'AI Infra Summit · Wiki', + bodyHtml: finalBodyHtml, + sourceAnswerIds: promotedAnswers.map((answer) => answer._id), + sourceIds: ['liveEventSources:1'], + createdAt: 1770000000000, + publishedAt: 1770000001000, + sections: promotedAnswers.map((answer, index) => ({ + id: 'faq-' + (index + 1), + title: answer.question, + body: answer.body, + sourceCount: answer.sourceCount, + })), + generatedAt: 1770000002000 + promotedAnswers.length, + }; + return Promise.resolve({ ok: true, eventId: args.eventId, version: 1, status: 'published' }); } if (name === 'events:requestJoinEvent') { window.__snRequestJoinArgs = args; @@ -117,30 +191,106 @@ async function fulfillScratchNodePage( requestId: 'liveEventJoinRequests:1', }); } + if (name === 'notes:createNote') { + const noteId = 'notes:' + (window.__snMockNotes.length + 1); + const note = { + _id: noteId, + eventId: args.eventId, + ownerKey: args.ownerKey, + title: args.title || 'Untitled', + bodyHtml: args.bodyHtml || '', + tags: args.tags || [], + isAsk: !!args.isAsk, + pinned: !!args.pinned, + anchorType: args.anchorType, + anchorId: args.anchorId, + anchorLabel: args.anchorLabel, + anchorPreview: args.anchorPreview, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + window.__snMockNotes.push(note); + const notifyNotes = window.__snMockSubscriptions['notes:listMyNotes']; + if (typeof notifyNotes === 'function') { + notifyNotes(window.__snMockNotes.slice()); + } + return Promise.resolve({ ok: true, noteId }); + } + if (name === 'notes:createNoteAnchor') { + const anchorId = 'noteAnchors:' + (window.__snMockAnchors.length + 1); + const anchor = { + _id: anchorId, + ownerKey: args.ownerKey, + eventId: args.eventId, + noteId: args.noteId, + targetKind: args.targetKind, + targetMessageId: args.targetMessageId, + targetAnswerId: args.targetAnswerId, + createdAt: Date.now(), + }; + window.__snMockAnchors.push(anchor); + const notifyAnchors = window.__snMockSubscriptions['notes:listMyAnchors']; + if (typeof notifyAnchors === 'function') { + notifyAnchors({ anchors: window.__snMockAnchors.slice() }); + } + return Promise.resolve({ ok: true, anchorId }); + } return Promise.resolve({}); } query(name) { if (name === 'events:getMyEvents') return Promise.resolve({ joined: [], hosted: [] }); - if (name === 'events:getPublishedWiki') { - if (!window.__snWikiPublished) return Promise.resolve(null); - return Promise.resolve({ - version: 1, - title: 'AI Infra Summit Wiki', - bodyHtml: '

AI Infra Summit Wiki

Published public memory.

', - sourceAnswerIds: ['liveEventAnswers:share1'], - sourceIds: ['liveEventSources:1'], - createdAt: 1770000000000, - publishedAt: 1770000001000, - }); - } + if (name === 'events:getPublishedWiki') return Promise.resolve(window.__snMockPublishedWiki); if (name === 'events:getHostStatus') { const token = localStorage.getItem('sn_host_owner_key_v2'); return Promise.resolve(token ? { isHost: true, role: 'owner', displayName: 'Mock Host' } : { isHost: false }); } return Promise.resolve([]); } - action() { return Promise.resolve(null); } + action(name, args) { + window.__snMockActions.push({ name, args }); + if (name === 'events:askAgent') { + const answerId = 'liveEventAnswers:' + (window.__snMockAnswers.length + 1); + const nextAnswer = { + _id: answerId, + question: args.question, + body: 'Mock sourced answer for ' + args.question, + questionMessageId: args.questionMessageId, + sourceCount: 2, + sources: [ + { title: 'Event wiki cache', uri: 'doc://event/wiki', excerpt: 'Public event context' }, + { title: 'Speaker notes', uri: 'doc://event/sources', excerpt: 'Host-uploaded public source' }, + ], + externalSearches: 0, + cacheHit: false, + estimatedCostCents: 0.0123, + evaluation: { score: 97 }, + trace: [ + { step: 'semantic_cache_lookup', status: 'ok', detail: 'public cache hit path' }, + { step: 'public_private_boundary', status: 'ok', detail: 'private notes excluded from retrieval, cache, and answer' }, + ], + createdAt: 1770000001000 + window.__snMockAnswers.length, + }; + window.__snMockAnswers.push(nextAnswer); + const notifyAnswers = window.__snMockSubscriptions['events:getAnswers']; + if (typeof notifyAnswers === 'function') { + notifyAnswers(window.__snMockAnswers.slice()); + } + return Promise.resolve(nextAnswer); + } + return Promise.resolve(null); + } onUpdate(name, _args, cb) { + window.__snMockSubscriptions[name] = cb; + if (name === 'events:getMessages') { + setTimeout(() => cb(window.__snMockMessages.slice()), 0); + } + if (name === 'events:getAnswers') { + const answers = [ + ...((window.__snAnswers || [])), + ...((window.__snMockAnswers || [])), + ]; + setTimeout(() => cb(answers), 0); + } if (name === 'events:getMembers') { setTimeout(() => cb([{ displayName: 'Mock Host' }, { displayName: 'Mock Guest' }]), 0); } @@ -152,10 +302,6 @@ async function fulfillScratchNodePage( const rooms = window.__snPublicRooms || []; setTimeout(() => cb({ rooms, activeWindowMs: 1800000 }), 0); } - if (name === 'events:getAnswers') { - const answers = window.__snAnswers || []; - setTimeout(() => cb(answers), 0); - } if (name === 'events:getMyJoinRequest') { const tick = () => { const status = window.__snJoinRequestStatus || 'pending'; @@ -172,6 +318,12 @@ async function fulfillScratchNodePage( const iv = setInterval(tick, 50); return () => clearInterval(iv); } + if (name === 'notes:listMyNotes') { + setTimeout(() => cb(window.__snMockNotes.slice()), 0); + } + if (name === 'notes:listMyAnchors') { + setTimeout(() => cb({ anchors: window.__snMockAnchors.slice() }), 0); + } } } `, @@ -247,9 +399,60 @@ test.describe("ScratchNode live route honesty", () => { await expect(page.locator("#ci")).toHaveValue("this must not be local-only"); }); + test("attendee room join stays a no-LLM membership event without public projections", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + await expect(page.locator(".row, .ans")).toHaveCount(0); + + const joinState = await page.evaluate(() => { + const win = window as any; + return { + joinCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:joinEvent", + ), + messageCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage", + ), + noteCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "notes:createNote", + ), + wikiCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:publishWiki", + ), + askActions: (win.__snMockActions || []).filter( + (call: any) => call.name === "events:askAgent", + ), + messages: win.__snMockMessages || [], + answers: win.__snMockAnswers || [], + notes: win.__snMockNotes || [], + publishedWiki: win.__snMockPublishedWiki, + }; + }); + + expect(joinState.joinCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + slug: "orbital", + displayName: expect.any(String), + sessionId: expect.any(String), + }), + }), + ]); + expect(joinState.messageCalls).toEqual([]); + expect(joinState.noteCalls).toEqual([]); + expect(joinState.wikiCalls).toEqual([]); + expect(joinState.askActions).toEqual([]); + expect(joinState.messages).toEqual([]); + expect(joinState.answers).toEqual([]); + expect(joinState.notes).toEqual([]); + expect(joinState.publishedWiki).toBeNull(); + }); + test("/ask answer Share copies a REAL link (no fake 'Shared' toast)", async ({ page }) => { - // The old lying handler (toasted "Answer link copied" while copying nothing) - // must be gone from the shipped file, and the honest helper must exist. expect(HOME_V5_HTML).not.toContain("toast('Shared'"); expect(HOME_V5_HTML).toContain("function _snShareAnswer"); @@ -257,11 +460,7 @@ test.describe("ScratchNode live route honesty", () => { await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); - // Force the clipboard path and capture what actually gets written — proving the - // Share button copies a real room link + the question, not a fake success toast. const written = await page.evaluate(() => { - // navigator.share lives on the prototype in Chromium — shadow it with an - // own undefined property so the helper takes the testable clipboard path. Object.defineProperty(navigator, "share", { value: undefined, configurable: true }); let captured = ""; Object.defineProperty(navigator, "clipboard", { @@ -272,10 +471,1687 @@ test.describe("ScratchNode live route honesty", () => { return captured; }); expect(written).toContain("What is the MCP auth timeline?"); - expect(written).toContain("/e/"); // a real room URL, not an empty string + expect(written).toContain("/e/"); expect(written).toContain("ScratchNode"); }); + test("normal public chat stays human and never invokes the agent", async ({ page }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const chatText = "does anyone have the link to the workshop deck?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, chatText); + + await expect(page.locator(".row-text", { hasText: chatText })).toHaveCount(1); + await expect(page.locator(".ans", { hasText: chatText })).toHaveCount(0); + + const chatState = await page.evaluate((text) => { + const win = window as any; + return { + actions: win.__snMockActions || [], + publicChatCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === text && + call.args?.kind === "chat", + ), + askCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.kind === "ask", + ), + }; + }, chatText); + + expect(chatState.actions).toEqual([]); + expect(chatState.publicChatCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + text: chatText, + kind: "chat", + eventId: "liveEvents:1", + }), + }), + ]); + expect(chatState.askCalls).toEqual([]); + }); + + test("normal public replies stay chat-only event-log moments", async ({ page }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const parentText = "Public parent message for reply context"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, parentText); + + const parentRow = page.locator(".row", { hasText: parentText }).first(); + await expect(parentRow.locator(".row-text")).toContainText(parentText); + const parentMessageId = await parentRow.getAttribute("data-mid"); + expect(parentMessageId).toMatch(/^liveEventMessages:/); + if (!parentMessageId) throw new Error("Expected parent message id"); + + const replyText = "Replying publicly with the source owner"; + await page.evaluate( + ({ parentMessageId, replyText }) => { + const win = window as any; + const input = document.getElementById("ci") as HTMLInputElement; + win.replyTo?.(parentMessageId); + input.value = replyText; + input.dispatchEvent(new Event("input", { bubbles: true })); + win.sendComposerMessage?.(); + }, + { parentMessageId, replyText }, + ); + + const replyRow = page.locator(`.row[data-reply-to-message-id="${parentMessageId}"]`, { + hasText: replyText, + }); + await expect(replyRow.locator(".row-text")).toContainText(replyText); + await expect(replyRow.locator(".row-replying")).toContainText(parentText); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "false"); + await expect(page.locator(".ans", { hasText: replyText })).toHaveCount(0); + + const replyState = await page.evaluate( + ({ parentMessageId, replyText }) => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + replySendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === replyText && + call.args?.kind === "chat", + ), + privateNoteCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "notes:createNote", + ), + askCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.kind === "ask", + ), + }; + }, + { parentMessageId, replyText }, + ); + + expect(replyState.noteCount).toBe(initialNoteCount); + expect(replyState.actions).toEqual([]); + expect(replyState.replySendCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + text: replyText, + kind: "chat", + replyToMessageId: parentMessageId, + }), + }), + ]); + expect(replyState.privateNoteCalls).toEqual([]); + expect(replyState.askCalls).toEqual([]); + }); + + test("typed people and company tags stay public-row context while private tagged follow-ups stay private", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const publicText = "@Alex Chen says #Orbital needs the VoiceLayer follow-up"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator(".row", { hasText: "VoiceLayer follow-up" }).first(); + await expect(publicRow.locator('.mention[data-member="Alex Chen"]')).toHaveText("@Alex Chen"); + await expect(publicRow.locator('.hashtag[data-event-log-tag="orbital"]')).toHaveText( + "#Orbital", + ); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + await page.locator("#lock").click(); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const privateText = "@Sarah Kim #MedLayer private follow-up on healthcare pilots"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator('.row .mention[data-member="Sarah Kim"]')).toHaveCount(0); + await expect(page.locator('.row .hashtag[data-event-log-tag="medlayer"]')).toHaveCount(0); + await expect(page.locator(".ans", { hasText: "healthcare pilots" })).toHaveCount(0); + + const state = await page.evaluate(({ publicText, privateText }) => { + const win = window as any; + return { + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === publicText, + ), + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === privateText, + ), + }; + }, { publicText, privateText }); + + expect(state.actions).toEqual([]); + expect(state.publicSendCalls).toHaveLength(1); + expect(state.publicSendCalls[0].args.kind).toBe("chat"); + expect(state.privateSendCalls).toEqual([]); + }); + + test("private notes anchored from people and company tags keep public ask context clean", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const publicText = "@Alex Chen says #Orbital and #VoiceLayer need a founder follow-up"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator(".row", { hasText: "founder follow-up" }).first(); + await expect(publicRow.locator('.mention[data-member="Alex Chen"]')).toHaveText("@Alex Chen"); + await expect(publicRow.locator('.hashtag[data-event-log-tag="orbital"]')).toHaveText( + "#Orbital", + ); + await expect(publicRow.locator('.hashtag[data-event-log-tag="voicelayer"]')).toHaveText( + "#VoiceLayer", + ); + const messageId = await publicRow.getAttribute("data-mid"); + expect(messageId).toMatch(/^liveEventMessages:/); + + await page.evaluate((mid) => { + (window as any).noteOnMessage?.(mid); + }, messageId); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "true"); + await expect(page.locator("#reply-ctx-quote")).toContainText("@Alex Chen says #Orbital"); + + const privateText = + "@Sarah Kim #MedLayer private diligence follow-up: ask for procurement owner"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "false"); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator('.row .mention[data-member="Sarah Kim"]')).toHaveCount(0); + await expect(page.locator('.row .hashtag[data-event-log-tag="medlayer"]')).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(publicRow.locator(".private-note-marker")).toHaveAttribute( + "aria-label", + "1 private note anchored here", + ); + + await page.locator("#lock").click(); + await expect(page.locator("body")).toHaveAttribute("data-mode", "public"); + + const publicPrompt = "what public founder follow-ups mention Orbital and VoiceLayer?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicPrompt); + + const answerCard = page.locator(".ans").filter({ hasText: publicPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + publicPrompt); + await expect(answerCard).toContainText("private notes excluded"); + await expect(answerCard).not.toContainText(privateText); + await expect(answerCard).not.toContainText("Sarah Kim"); + await expect(answerCard).not.toContainText("MedLayer"); + + const anchorState = await page.evaluate( + ({ privateText, publicText, publicPrompt, messageId }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + note, + actions: win.__snMockActions || [], + serializedAnswers: JSON.stringify(win.__snMockAnswers || []), + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && call.args?.text === privateText, + ), + publicAskCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicPrompt && + call.args?.kind === "ask", + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicText && + call.args?.kind === "chat", + ), + markerCount: document.querySelectorAll( + `.row[data-mid="${messageId}"] .private-note-marker`, + ).length, + }; + }, + { privateText, publicText, publicPrompt, messageId }, + ); + + expect(anchorState.note).toEqual( + expect.objectContaining({ + anchorType: "message", + anchorId: messageId, + anchorPreview: publicText, + }), + ); + expect(anchorState.actions).toEqual([ + expect.objectContaining({ + name: "events:askAgent", + args: expect.objectContaining({ + question: publicPrompt, + }), + }), + ]); + expect(anchorState.serializedAnswers).not.toContain(privateText); + expect(anchorState.serializedAnswers).not.toContain("Sarah Kim"); + expect(anchorState.serializedAnswers).not.toContain("MedLayer"); + expect(anchorState.privateSendCalls).toEqual([]); + expect(anchorState.publicAskCalls).toHaveLength(1); + expect(anchorState.publicSendCalls).toHaveLength(1); + expect(anchorState.markerCount).toBe(1); + }); + + test("locked composer saves a private note without public chat or agent calls", async ({ page }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + await page.locator("#lock").click(); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const noteText = "private note: ask Priya for the clinical triage deck"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, noteText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: noteText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: noteText })).toHaveCount(0); + + const privateNoteState = await page.evaluate((text) => { + const win = window as any; + const handoffUrl = win.buildNodeBenchEventPrivateUrl?.(); + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + handoffUrl, + signInUrl: win.buildNodeBenchSignInUrl?.(handoffUrl), + actions: win.__snMockActions || [], + sendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === text, + ), + }; + }, noteText); + + expect(privateNoteState.noteCount).toBe(initialNoteCount + 1); + expect(privateNoteState.handoffUrl).toContain(`noteCount=${initialNoteCount + 1}`); + expect(privateNoteState.handoffUrl).toContain("continuation=private-notes"); + const handoffUrl = new URL(privateNoteState.handoffUrl); + expect(handoffUrl.origin).toBe("https://nodebenchai.com"); + expect(handoffUrl.pathname).toBe("/scratchnode-events"); + expect(privateNoteState.handoffUrl).not.toMatch(/\/events\/[^/]+\/private/); + expect(handoffUrl.searchParams.get("source")).toBe("scratchnode"); + expect(handoffUrl.searchParams.get("publicArtifact")).toBe("event-wiki"); + expect(handoffUrl.searchParams.get("return")).toBe("https://scratchnode.live/e/ai-infra-summit-2026"); + const signInUrl = new URL(privateNoteState.signInUrl); + expect(signInUrl.origin).toBe("https://nodebenchai.com"); + expect(signInUrl.pathname).toBe("/sign-in"); + expect(signInUrl.searchParams.get("intent")).toBe("save-private-notes"); + expect(signInUrl.searchParams.get("return")).toBe(privateNoteState.handoffUrl); + expect(privateNoteState.actions).toEqual([]); + expect(privateNoteState.sendCalls).toEqual([]); + }); + + test("manual location spots render as public event-log chips without private leakage", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const publicText = "Meet at Booth 12 before the MCP auth panel"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator('.row[data-location-spot="Booth 12"]', { + hasText: publicText, + }); + await expect(publicRow.locator(".row-text")).toContainText(publicText); + await expect(publicRow.locator(".sn-location-spot")).toHaveText("at Booth 12"); + + const publicSpotCases = [ + { spot: "Lobby", text: "Lobby meetup before the founder demos" }, + { spot: "Panel Room A", text: "Panel Room A recap notes for the public event log" }, + { spot: "Afterparty", text: "Afterparty logistics moved to the rooftop" }, + ]; + for (const { spot, text } of publicSpotCases) { + await page.evaluate((message) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = message; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, text); + + const row = page.locator(`.row[data-location-spot="${spot}"]`, { hasText: text }); + await expect(row.locator(".row-text")).toContainText(text); + await expect(row.locator(".sn-location-spot")).toHaveText(`at ${spot}`); + } + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + await page.evaluate(() => { + if (document.body.getAttribute("data-mode") !== "private") { + (window as any).toggleLock?.(); + } + }); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const privateText = "private follow-up from Investor Lounge: ask Priya for the sponsor list"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator('.row[data-location-spot="Investor Lounge"]')).toHaveCount(0); + + const state = await page.evaluate((text) => { + const win = window as any; + return { + hasGeolocationApi: + /navigator\.geolocation|getCurrentPosition|watchPosition/.test( + document.documentElement.innerHTML, + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === text, + ), + }; + }, publicText); + + expect(state.hasGeolocationApi).toBe(false); + expect(state.publicSendCalls).toHaveLength(1); + expect(state.publicSendCalls[0].args.kind).toBe("chat"); + }); + + test("private notes anchored from manual location spots preserve context without public leakage", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const publicText = "Meet at Booth 12 before the MCP auth panel"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator('.row[data-location-spot="Booth 12"]', { + hasText: publicText, + }); + await expect(publicRow.locator(".row-text")).toContainText(publicText); + await expect(publicRow.locator(".sn-location-spot")).toHaveText("at Booth 12"); + const messageId = await publicRow.getAttribute("data-mid"); + expect(messageId).toMatch(/^liveEventMessages:/); + + await page.evaluate((mid) => { + (window as any).noteOnMessage?.(mid); + }, messageId); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "true"); + await expect(page.locator("#reply-ctx-quote")).toContainText(publicText); + + const privateText = "private booth follow-up: ask Sarah which sponsor owns Booth 12"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "false"); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(publicRow.locator(".private-note-marker")).toHaveAttribute( + "aria-label", + "1 private note anchored here", + ); + + const anchorState = await page.evaluate( + ({ privateText, publicText, messageId }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + note, + actions: win.__snMockActions || [], + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && call.args?.text === privateText, + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicText && + call.args?.kind === "chat", + ), + markerCount: document.querySelectorAll( + `.row[data-mid="${messageId}"] .private-note-marker`, + ).length, + locationMarkerCount: document.querySelectorAll( + `.row[data-location-spot="Booth 12"] .private-note-marker`, + ).length, + }; + }, + { privateText, publicText, messageId }, + ); + + expect(anchorState.note).toEqual( + expect.objectContaining({ + anchorType: "message", + anchorId: messageId, + anchorPreview: publicText, + }), + ); + expect(anchorState.actions).toEqual([]); + expect(anchorState.privateSendCalls).toEqual([]); + expect(anchorState.publicSendCalls).toHaveLength(1); + expect(anchorState.markerCount).toBe(1); + expect(anchorState.locationMarkerCount).toBe(1); + }); + + test("private notes anchored from public messages preserve context without public leakage", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const publicText = "public chat anchor source for private note test"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator(".row", { hasText: publicText }).first(); + await expect(publicRow.locator(".row-text")).toContainText(publicText); + const messageId = await publicRow.getAttribute("data-mid"); + expect(messageId).toMatch(/^liveEventMessages:/); + + await page.evaluate((mid) => { + (window as any).noteOnMessage?.(mid); + }, messageId); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "true"); + await expect(page.locator("#reply-ctx-quote")).toContainText(publicText); + + const privateText = "private anchored note: ask Alex about the auth timeline"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator("#reply-ctx")).toHaveAttribute("data-open", "false"); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(publicRow.locator(".private-note-marker")).toHaveAttribute( + "aria-label", + "1 private note anchored here", + ); + + const anchorState = await page.evaluate( + ({ privateText, messageId, publicText }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + note, + actions: win.__snMockActions || [], + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === privateText, + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicText && + call.args?.kind === "chat", + ), + markerCount: document.querySelectorAll( + `.row[data-mid="${messageId}"] .private-note-marker`, + ).length, + }; + }, + { privateText, messageId, publicText }, + ); + + expect(anchorState.note).toEqual( + expect.objectContaining({ + anchorType: "message", + anchorId: messageId, + anchorPreview: publicText, + }), + ); + expect(anchorState.actions).toEqual([]); + expect(anchorState.privateSendCalls).toEqual([]); + expect(anchorState.publicSendCalls).toHaveLength(1); + expect(anchorState.markerCount).toBe(1); + }); + + test("private notes anchored from public answers preserve context without public leakage", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const publicPrompt = "which MCP auth answer should get a private follow-up?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicPrompt); + + const answerCard = page.locator(".ans").filter({ hasText: publicPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + publicPrompt); + await expect(answerCard).toContainText("private notes excluded"); + const answerId = await answerCard.getAttribute("data-answer-id"); + expect(answerId).toBe("liveEventAnswers:1"); + if (!answerId) throw new Error("Expected answer id"); + + await page.evaluate((id) => { + (window as any).snAnchorTo?.("answer", id); + (window as any).toggleLock?.(); + }, answerId); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const privateText = "private answer follow-up: ask Alex for the source provenance risk"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(answerCard.locator(".sn-anchor-pin")).toHaveAttribute( + "aria-label", + "Open anchored private note", + ); + + const anchorState = await page.evaluate( + ({ answerId, privateText, publicPrompt }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + note, + pendingAnchor: win._sn_pending_anchor, + anchorCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "notes:createNoteAnchor", + ), + publicAskCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === publicPrompt && + call.args?.kind === "ask", + ), + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + call.args?.text === privateText, + ), + actions: win.__snMockActions || [], + serializedAnswers: JSON.stringify(win.__snMockAnswers || []), + markerCount: document.querySelectorAll( + `.ans[data-answer-id="${answerId}"] .sn-anchor-pin`, + ).length, + targetAnchors: Array.from(win._sn_anchors_by_target?.keys?.() || []), + }; + }, + { answerId, privateText, publicPrompt }, + ); + + expect(anchorState.note).toEqual( + expect.objectContaining({ + id: "notes:1", + }), + ); + expect(anchorState.pendingAnchor).toBeNull(); + expect(anchorState.anchorCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + eventId: "liveEvents:1", + noteId: "notes:1", + targetKind: "answer", + targetAnswerId: answerId, + }), + }), + ]); + expect(anchorState.anchorCalls[0].args.targetMessageId).toBeUndefined(); + expect(anchorState.publicAskCalls).toHaveLength(1); + expect(anchorState.privateSendCalls).toEqual([]); + expect(anchorState.actions).toEqual([ + expect.objectContaining({ + name: "events:askAgent", + args: expect.objectContaining({ + question: publicPrompt, + }), + }), + ]); + expect(anchorState.serializedAnswers).not.toContain(privateText); + expect(anchorState.markerCount).toBe(1); + expect(anchorState.targetAnchors).toContain(`answer:${answerId}`); + }); + + test("sensitive event mode forces /ask into private notes without agent calls", async ({ page }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + await page.evaluate(() => (window as any).setEventMode?.("sensitive")); + await expect(page.locator("body")).toHaveAttribute("data-event-mode", "sensitive"); + + const sensitivePrompt = "summarize the private vendor pricing concern"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, sensitivePrompt); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: sensitivePrompt })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: sensitivePrompt })).toHaveCount(0); + + const sensitiveState = await page.evaluate((text) => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + sendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === text, + ), + noteTexts: (win._notes_v5 || []).map((note: any) => note.title + "\n" + note.body), + }; + }, sensitivePrompt); + + expect(sensitiveState.noteCount).toBe(initialNoteCount + 1); + expect(sensitiveState.actions).toEqual([]); + expect(sensitiveState.sendCalls).toEqual([]); + expect(sensitiveState.noteTexts.join("\n")).toContain(sensitivePrompt); + }); + + test("Live Assist save cue writes an actual private note without public writes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const cueText = "Ask Alex for the latency source after the MCP panel"; + const cueId = await page.evaluate((text) => { + const win = window as any; + win.toggleLiveAssist?.(true); + return win.pushLiveAssistCue?.(text, { source: "route-test", skill: "cue-save" }); + }, cueText); + + await expect(page.locator("#live-assist-rail")).toContainText(cueText); + await page.evaluate((id) => { + (window as any)._laCueAction?.("save", id); + }, cueId); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: cueText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: cueText })).toHaveCount(0); + + const state = await page.evaluate((text) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(text), + ); + const noteText = String((note?.title || "") + "\n" + (note?.body || "")) + .replace(//gi, "\n"); + return { + noteText, + noteCount: win.getPrivateNoteHandoffCount?.(), + recentNotes: (win._live_assist?.recentNotes || []).map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(state.noteCount).toBe(initialNoteCount + 1); + expect(state.noteText).toContain(`Cue: ${cueText}`); + expect(state.recentNotes.join("\n")).toContain(`Cue: ${cueText}`); + expect(state.actions).toEqual([]); + expect(state.publicSendCalls).toEqual([]); + }); + + test("Live Assist ask privately cue drafts first, then sends only to private notes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const cueText = "Which source proves tail latency?"; + const draftState = await page.evaluate((text) => { + const win = window as any; + const input = document.getElementById("ci") as HTMLInputElement; + win.__snInputEvents = 0; + input.addEventListener("input", () => { + win.__snInputEvents += 1; + }); + win.toggleLiveAssist?.(true); + const cueId = win.pushLiveAssistCue?.(text, { source: "route-test", skill: "cue-ask-private" }); + win._laCueAction?.("ask-private", cueId); + return { + draft: input.value, + inputEvents: win.__snInputEvents, + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(draftState.draft).toBe(`/ask private ${cueText}`); + expect(draftState.inputEvents).toBeGreaterThan(0); + expect(draftState.selectionStart).toBe(draftState.draft.length); + expect(draftState.selectionEnd).toBe(draftState.draft.length); + expect(draftState.noteCount).toBe(initialNoteCount); + expect(draftState.actions).toEqual([]); + expect(draftState.publicSendCalls).toEqual([]); + + await page.evaluate(() => { + (window as any).sendComposerMessage?.(); + }); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: cueText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: cueText })).toHaveCount(0); + + const sentState = await page.evaluate((text) => { + const win = window as any; + const noteTexts = (win._notes_v5 || []).map((note: any) => note.title + "\n" + note.body); + return { + noteTexts, + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(sentState.noteTexts.join("\n")).toContain(cueText); + expect(sentState.actions).toEqual([]); + expect(sentState.publicSendCalls).toEqual([]); + }); + + test("Live Assist follow-up cues require explicit action before private note creation", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const cueText = "Clarify scoped tool grant vs tenant RBAC"; + const cueId = await page.evaluate((text) => { + const win = window as any; + win.toggleLiveAssist?.(true); + win.setLiveAssistTopic?.("MCP auth", "Panel Room A"); + win.setLiveAssistContext?.(["@Orbital Labs", "@Alex Chen", "[[tenant RBAC]]"]); + return win.pushLiveAssistCue?.(text, { source: "route-test", skill: "follow-up-depth" }); + }, cueText); + + await expect(page.locator("#live-assist-rail")).toContainText(cueText); + const beforeAction = await page.evaluate((text) => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + noteTexts: (win._notes_v5 || []).map((note: any) => note.title + "\n" + note.body), + recentNotes: (win._live_assist?.recentNotes || []).map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(beforeAction.noteCount).toBe(initialNoteCount); + expect(beforeAction.noteTexts.join("\n")).not.toContain(cueText); + expect(beforeAction.recentNotes.join("\n")).not.toContain(cueText); + expect(beforeAction.actions).toEqual([]); + expect(beforeAction.publicSendCalls).toEqual([]); + + await page.evaluate((id) => { + (window as any)._laCueAction?.("followup", id); + }, cueId); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: cueText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: cueText })).toHaveCount(0); + + const state = await page.evaluate((text) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(text), + ); + const noteText = String((note?.title || "") + "\n" + (note?.body || "")) + .replace(//gi, "\n") + .replace(/&/g, "&"); + return { + noteText, + recentNotes: (win._live_assist?.recentNotes || []).map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, cueText); + + expect(state.noteText).toContain(`Follow-up: ${cueText}`); + expect(state.noteText).toContain("Why it matters: Deepen this after the event in NodeBench"); + expect(state.noteText).toContain("Next step: Ask for the concrete decision"); + expect(state.noteText).toContain("Evidence to capture: quote, speaker/company"); + expect(state.noteText).toContain( + "NodeBench packet: people, companies, topics, anchors, source refs, and open questions.", + ); + expect(state.noteText).toContain( + "Deeper follow-up: turn this cue into one owner-scoped research task, not a public room answer.", + ); + expect(state.noteText).toContain("Event topic: MCP auth - Panel Room A"); + expect(state.noteText).toContain("Context: @Orbital Labs, @Alex Chen, [[tenant RBAC]]"); + expect(state.noteText).toContain("Cue source: route-test"); + expect(state.noteText).toContain("Cue skill: follow-up-depth"); + expect(state.noteText).toContain(`Cue trace: trace_${cueId}`); + expect(state.noteText).toContain("Visibility: private follow-up note; not public chat or public /ask."); + expect(state.recentNotes.join("\n")).toContain(`Follow-up: ${cueText}`); + expect(state.actions).toEqual([]); + expect(state.publicSendCalls).toEqual([]); + }); + + test("Live Assist voice transcript saves as a private note without public writes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const transcript = "Voice note: ask Alex for the source behind sub-350ms clinical latency"; + const captureState = await page.evaluate((text) => { + const win = window as any; + win.toggleLiveAssist?.(true); + win.laStartVoice?.(); + win.laUpdateVoice?.("transcribing", ""); + win.laUpdateVoice?.("transcribed", text); + return { + voiceInLiveAssist: !!document.querySelector( + "#live-assist-rail .la-card.voice, #live-assist-sheet .la-card.voice", + ), + voiceInFeed: !!document.querySelector("#feed .voice-capture"), + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, transcript); + + expect(captureState.voiceInLiveAssist).toBe(true); + expect(captureState.voiceInFeed).toBe(false); + expect(captureState.noteCount).toBe(initialNoteCount); + expect(captureState.actions).toEqual([]); + expect(captureState.publicSendCalls).toEqual([]); + await expect(page.locator("#live-assist-rail")).toContainText(transcript); + await expect(page.locator("#feed .voice-capture")).toHaveCount(0); + + await page.evaluate((text) => { + const win = window as any; + win.saveLiveAssistPrivateNote?.(text, "voice"); + win.laUpdateVoice?.("saved", text); + }, transcript); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: transcript })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: transcript })).toHaveCount(0); + + const savedState = await page.evaluate((text) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(text), + ); + const noteText = String((note?.title || "") + "\n" + (note?.body || "")) + .replace(//gi, "\n"); + return { + noteText, + noteCount: win.getPrivateNoteHandoffCount?.(), + voiceState: win._live_assist?.voice?.state, + recentVoiceNotes: (win._live_assist?.recentNotes || []) + .filter((entry: any) => entry.source === "voice") + .map((entry: any) => entry.text), + actions: win.__snMockActions || [], + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => + call.name === "events:sendMessage" && + String(call.args?.text || "").includes(text), + ), + }; + }, transcript); + + expect(savedState.noteCount).toBe(initialNoteCount + 1); + expect(savedState.noteText).toContain(transcript); + expect(savedState.voiceState).toBe("saved"); + expect(savedState.recentVoiceNotes.join("\n")).toContain(transcript); + expect(savedState.actions).toEqual([]); + expect(savedState.publicSendCalls).toEqual([]); + }); + + test("public photo evidence markers stay event-log only while private photo follow-ups stay private", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const publicText = "photo: Booth 12 latency board for #Orbital"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicText); + + const publicRow = page.locator('.row[data-event-log-media="photo"]', { + hasText: publicText, + }); + await expect(publicRow.locator(".row-text")).toContainText(publicText); + await expect(publicRow.locator('.sn-photo-evidence[data-event-log-media="photo"]')).toHaveText( + "photo evidence", + ); + await expect(publicRow.locator('.hashtag[data-event-log-tag="orbital"]')).toHaveText( + "#Orbital", + ); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + await page.evaluate(() => { + if (document.body.getAttribute("data-mode") !== "private") { + (window as any).toggleLock?.(); + } + }); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + + const privateText = "photo: private sponsor board follow-up for MedLayer buyers"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privateText })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privateText })).toHaveCount(0); + await expect(page.locator('.row[data-event-log-media="photo"]', { hasText: privateText })).toHaveCount(0); + + const state = await page.evaluate(({ privateText, publicText }) => { + const win = window as any; + const note = (win._notes_v5 || []).find((entry: any) => + String(entry.title + "\n" + entry.body).includes(privateText), + ); + return { + noteText: String((note?.title || "") + "\n" + (note?.body || "")).replace( + //gi, + "\n", + ), + photoRows: document.querySelectorAll('.row[data-event-log-media="photo"]').length, + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === privateText, + ), + publicSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === publicText, + ), + actions: win.__snMockActions || [], + }; + }, { privateText, publicText }); + + expect(state.noteText).toContain(privateText); + expect(state.photoRows).toBe(1); + expect(state.privateSendCalls).toEqual([]); + expect(state.publicSendCalls).toHaveLength(1); + expect(state.publicSendCalls[0].args.kind).toBe("chat"); + expect(state.actions).toEqual([]); + }); + + test("private /ask stays out of the public feed and increases the NodeBench handoff note count", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const privatePrompt = "what follow-up should I save for after the summit?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask private ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privatePrompt); + + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + await expect(page.locator("#pn-inline")).toHaveAttribute("data-count", String(initialNoteCount + 1)); + await expect(page.locator(".row-text", { hasText: privatePrompt })).toHaveCount(0); + await expect(page.locator(".ans", { hasText: privatePrompt })).toHaveCount(0); + await expect(page.locator("#pn-inline")).toContainText("private note(s) saved this event"); + + const privateState = await page.evaluate((text) => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + handoffUrl: win.buildNodeBenchEventPrivateUrl?.(), + sendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === text, + ).length, + }; + }, privatePrompt); + + expect(privateState.noteCount).toBe(initialNoteCount + 1); + expect(privateState.handoffUrl).toContain("continuation=private-notes"); + expect(privateState.handoffUrl).toContain(`noteCount=${initialNoteCount + 1}`); + expect(privateState.handoffUrl).toContain("publicArtifact=event-wiki"); + expect(privateState.sendCalls).toBe(0); + + await page.evaluate(() => (window as any).openNotes?.()); + await expect(page.locator("#sheet-title")).toContainText("My notes"); + await expect(page.locator("#sheet-content")).toContainText(privatePrompt); + await expect(page.locator("#sheet-content")).toContainText( + "deeper research, reports, and follow-ups across people, companies, topics, and anchors", + ); + await expect(page.locator("#sheet-content")).toContainText("Open NodeBench event notebook"); + await expect(page.locator("#sn-nodebench-private-handoff")).toBeVisible(); + }); + + test("public /ask after a private note still excludes private note text", async ({ page }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const initialNoteCount = await page.evaluate(() => { + const win = window as any; + win.ensureNotesStore?.(); + return win.getPrivateNoteHandoffCount?.() ?? 0; + }); + + const privateText = "private note: portfolio company diligence thread"; + await page.locator("#lock").click(); + await expect(page.locator("body")).toHaveAttribute("data-mode", "private"); + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateText); + await expect + .poll(() => page.locator("#pn-inline-count").textContent(), { timeout: 5_000 }) + .toBe(String(initialNoteCount + 1)); + + await page.locator("#lock").click(); + await expect(page.locator("body")).toHaveAttribute("data-mode", "public"); + + const publicPrompt = "what are the public follow-ups from the MCP panel?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicPrompt); + + await expect(page.locator(".row-text", { hasText: publicPrompt })).toHaveCount(1); + const answerCard = page.locator(".ans").filter({ hasText: publicPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + publicPrompt); + await expect(answerCard).toContainText("private notes excluded"); + await expect(answerCard).not.toContainText(privateText); + + const publicAskState = await page.evaluate((privateNoteText) => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.(), + actions: win.__snMockActions || [], + serializedAnswers: JSON.stringify(win.__snMockAnswers || []), + publicAskCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.kind === "ask", + ), + privateSendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.text === privateNoteText, + ), + }; + }, privateText); + + expect(publicAskState.noteCount).toBe(initialNoteCount + 1); + expect(publicAskState.publicAskCalls).toHaveLength(1); + expect(publicAskState.privateSendCalls).toEqual([]); + expect(publicAskState.actions).toEqual([ + expect.objectContaining({ + name: "events:askAgent", + args: expect.objectContaining({ + question: publicPrompt, + }), + }), + ]); + expect(publicAskState.serializedAnswers).not.toContain(privateText); + }); + + test("public /ask keeps the parent ask visible and shows FAQ/wiki actions with a public-only trace", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + + const publicPrompt = "what changed in the MCP auth timeline?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicPrompt); + + await expect(page.locator(".row-text", { hasText: publicPrompt })).toHaveCount(1); + const answerCard = page.locator(".ans").filter({ hasText: publicPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + publicPrompt); + await expect(answerCard).toContainText("private notes excluded"); + await expect(answerCard).toContainText("Suggest for FAQ"); + await expect(answerCard).toContainText("View in wiki"); + await expect(answerCard.getByRole("button", { name: "Promote to FAQ" })).toHaveCount(0); + await answerCard.getByRole("button", { name: "Suggest for FAQ" }).click(); + await expect + .poll(() => + page.evaluate( + () => + ((window as any).__snMockMutations || []).filter( + (call: any) => call.name === "events:suggestAnswerForFaq", + ).length, + ), + ) + .toBe(1); + await answerCard.getByRole("button", { name: /Pin to wall/i }).click(); + await expect + .poll(() => + page.evaluate( + () => ((window as any).__snMockMutations || []).filter((call: any) => call.name === "wall:pinToWall").length, + ), + ) + .toBe(1); + const wikiButton = answerCard.locator("button").filter({ hasText: /View in wiki/i }).last(); + await expect(wikiButton).toHaveCount(1); + await wikiButton.evaluate((button: HTMLButtonElement) => button.click()); + await expect(page.locator("#sheet-title")).toContainText("AI Infra Summit"); + await expect(page.locator("#sheet-content")).toContainText("Wiki not published yet"); + await expect(page.locator("#sheet-content")).not.toContainText(publicPrompt); + + const publicAskState = await page.evaluate(() => { + const win = window as any; + return { + noteCount: win.getPrivateNoteHandoffCount?.() ?? 0, + sendCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:sendMessage" && call.args?.kind === "ask", + ).length, + answers: (win.__snMockAnswers || []).map((answer: any) => ({ + question: answer.question, + questionMessageId: answer.questionMessageId, + trace: answer.trace, + })), + faqSuggestionCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:suggestAnswerForFaq", + ), + wallPinCalls: (win.__snMockMutations || []).filter((call: any) => call.name === "wall:pinToWall"), + hostOnlyCalls: (win.__snMockMutations || []).filter((call: any) => + ["events:promoteAnswerToFaq", "events:publishWiki"].includes(call.name), + ), + }; + }); + + expect(publicAskState.noteCount).toBe(0); + expect(publicAskState.sendCalls).toBe(1); + expect(publicAskState.answers).toHaveLength(1); + expect(publicAskState.answers[0].question).toBe(publicPrompt); + expect(publicAskState.answers[0].questionMessageId).toBe("liveEventMessages:1"); + expect(publicAskState.answers[0].trace).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + detail: expect.stringContaining("private notes excluded"), + }), + ]), + ); + expect(publicAskState.faqSuggestionCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + answerId: "liveEventAnswers:1", + eventId: "liveEvents:1", + }), + }), + ]); + expect(publicAskState.wallPinCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + eventId: "liveEvents:1", + refAnswerId: "liveEventAnswers:1", + refType: "answer", + }), + }), + ]); + expect(publicAskState.hostOnlyCalls).toEqual([]); + }); + + test("verified host can promote a public ask answer without publishing the wiki", async ({ page }) => { + await fulfillScratchNodePage(page); + const ownerKey = "hk1:liveEvents:1:nonce:1770000000000:abcdefabcdefabcdefabcdefabcdef12"; + await page.addInitScript((key) => { + localStorage.setItem("sn_host_owner_key_v2", key); + }, ownerKey); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + await expect.poll(() => page.evaluate(() => document.body.getAttribute("data-role")), { timeout: 5_000 }).toBe("host"); + + const hostPrompt = "which source should become the host FAQ?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, hostPrompt); + + const answerCard = page.locator(".ans").filter({ hasText: hostPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + hostPrompt); + await expect(answerCard).toContainText("Promote to FAQ"); + await answerCard.getByRole("button", { name: "Promote to FAQ" }).click(); + await expect + .poll(() => + page.evaluate( + () => + ((window as any).__snMockMutations || []).filter( + (call: any) => call.name === "events:promoteAnswerToFaq", + ).length, + ), + ) + .toBe(1); + + const hostPromotionState = await page.evaluate(() => { + const win = window as any; + return { + promotionCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:promoteAnswerToFaq", + ), + attendeeOnlyCalls: (win.__snMockMutations || []).filter( + (call: any) => call.name === "events:suggestAnswerForFaq", + ), + publishCalls: (win.__snMockMutations || []).filter((call: any) => call.name === "events:publishWiki"), + }; + }); + + expect(hostPromotionState.promotionCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + answerId: "liveEventAnswers:1", + eventId: "liveEvents:1", + ownerKey, + }), + }), + ]); + expect(hostPromotionState.attendeeOnlyCalls).toEqual([]); + expect(hostPromotionState.publishCalls).toEqual([]); + }); + + test("verified host can publish a wiki snapshot without sending private note text", async ({ page }) => { + await fulfillScratchNodePage(page); + const ownerKey = "hk1:liveEvents:1:nonce:1770000000000:abcdefabcdefabcdefabcdefabcdef12"; + await page.addInitScript((key) => { + localStorage.setItem("sn_host_owner_key_v2", key); + }, ownerKey); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + await expect.poll(() => page.evaluate(() => document.body.getAttribute("data-role")), { timeout: 5_000 }).toBe("host"); + + const privateNoteText = "private board note: acquisition diligence call with Priya"; + const initialNoteCount = await page.evaluate(() => (window as any).getPrivateNoteHandoffCount?.() ?? 0); + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask private ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateNoteText); + await expect + .poll(() => page.evaluate(() => (window as any).getPrivateNoteHandoffCount?.() ?? 0), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(initialNoteCount + 1); + + await page.evaluate(() => (window as any).openSheet("host")); + await page.getByRole("button", { name: "Publish wiki snapshot" }).click(); + await expect + .poll(() => + page.evaluate( + () => + ((window as any).__snMockMutations || []).filter((call: any) => call.name === "events:publishWiki") + .length, + ), + ) + .toBe(1); + + const publishState = await page.evaluate(() => { + const win = window as any; + return { + publishCalls: (win.__snMockMutations || []).filter((call: any) => call.name === "events:publishWiki"), + answerCalls: (win.__snMockMutations || []).filter((call: any) => + ["events:suggestAnswerForFaq", "events:promoteAnswerToFaq"].includes(call.name), + ), + }; + }); + + expect(publishState.publishCalls).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ + eventId: "liveEvents:1", + ownerKey, + }), + }), + ]); + expect(JSON.stringify(publishState.publishCalls[0].args)).not.toContain(privateNoteText); + expect(publishState.answerCalls).toEqual([]); + }); + + test("verified host publishes promoted public answers into the wiki without leaking private notes", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + const ownerKey = "hk1:liveEvents:1:nonce:1770000000000:abcdefabcdefabcdefabcdefabcdef12"; + await page.addInitScript((key) => { + localStorage.setItem("sn_host_owner_key_v2", key); + }, ownerKey); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + await expect.poll(() => page.evaluate(() => document.body.getAttribute("data-role")), { timeout: 5_000 }).toBe("host"); + + const publicPrompt = "what changed in the MCP auth timeline?"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, publicPrompt); + + const answerCard = page.locator(".ans").filter({ hasText: publicPrompt }).first(); + await expect(answerCard).toContainText("Mock sourced answer for " + publicPrompt); + await answerCard.getByRole("button", { name: "Promote to FAQ" }).click(); + await expect + .poll(() => + page.evaluate( + () => + ((window as any).__snMockMutations || []).filter( + (call: any) => call.name === "events:promoteAnswerToFaq", + ).length, + ), + ) + .toBe(1); + + const privateNoteText = "private board note: acquisition diligence call with Priya"; + await page.evaluate((text) => { + const input = document.getElementById("ci") as HTMLInputElement; + input.value = `/ask private ${text}`; + input.dispatchEvent(new Event("input", { bubbles: true })); + (window as any).sendComposerMessage(); + }, privateNoteText); + await expect + .poll(() => page.evaluate(() => (window as any).getPrivateNoteHandoffCount?.() ?? 0), { timeout: 5_000 }) + .toBeGreaterThan(0); + + await page.evaluate(() => (window as any).openSheet("host")); + await page.getByRole("button", { name: "Publish wiki snapshot" }).click(); + await expect + .poll(() => + page.evaluate( + () => + ((window as any).__snMockMutations || []).filter((call: any) => call.name === "events:publishWiki") + .length, + ), + ) + .toBe(1); + await expect.poll(() => page.evaluate(() => (window as any)._sn_published_wiki_body ?? ""), { timeout: 5_000 }).toContain( + publicPrompt, + ); + + const publishedWikiButton = answerCard.locator("button").filter({ hasText: /View in wiki/i }).last(); + await expect(publishedWikiButton).toHaveCount(1); + await publishedWikiButton.evaluate((button: HTMLButtonElement) => button.click()); + await expect(page.locator("#sheet-title")).toContainText("AI Infra Summit"); + await expect(page.locator("#sheet-content")).toContainText(publicPrompt); + await expect(page.locator("#sheet-content")).toContainText("Mock sourced answer for " + publicPrompt); + await expect(page.locator("#sheet-content")).not.toContainText(privateNoteText); + await expect(page.locator("#sheet-content")).toContainText("Your private notes never enter the wiki"); + + const publishedWiki = await page.evaluate(() => (window as any).__snMockPublishedWiki); + expect(publishedWiki).toMatchObject({ + eventId: "liveEvents:1", + version: 1, + }); + expect(publishedWiki.sections).toEqual([ + expect.objectContaining({ + title: publicPrompt, + body: "Mock sourced answer for " + publicPrompt, + }), + ]); + expect(JSON.stringify(publishedWiki)).not.toContain(privateNoteText); + }); + test("live wiki and people sheets do not show stale static launch counts", async ({ page }) => { await fulfillScratchNodePage(page); @@ -442,6 +2318,32 @@ test.describe("ScratchNode live route honesty", () => { .toMatchObject({ eventId: "liveEvents:1" }); await expect(page.locator("#sn-manage-event-output")).toContainText("Session ended"); await expect(page.locator("#ev-mode-label")).toContainText("ended"); + + const hostWorkflowState = await page.evaluate(() => { + const win = window as any; + return { + actions: win.__snMockActions || [], + hostMutations: (win.__snMockMutations || []) + .filter((call: any) => + [ + "events:updateEvent", + "events:upsertEventSource", + "events:deleteEventSource", + "events:endEvent", + ].includes(call.name), + ) + .map((call: any) => call.name), + }; + }); + expect(hostWorkflowState.actions).toEqual([]); + expect(hostWorkflowState.hostMutations).toEqual( + expect.arrayContaining([ + "events:updateEvent", + "events:upsertEventSource", + "events:deleteEventSource", + "events:endEvent", + ]), + ); }); test("landing 'Create a room' creates a live room and enters it as host", async ({ page }) => { @@ -467,19 +2369,16 @@ test.describe("ScratchNode live route honesty", () => { roomCode: "BIRTHDAY", status: "live", }); - // Viral loop: the host lands on the "your event is live, invite now" moment - // (NOT dropped straight into the room) with a shareable invite card. await expect(page.locator("#share-moment")).toHaveAttribute("data-open", "true"); await expect(page.locator("#invite-card-code")).toHaveText("BIRTHDAY"); await expect(page.locator("#share-link-input")).toHaveValue(/\/e\/launch-room$/); await expect(page.locator("#invite-card-qr")).toHaveAttribute("src", /create-qr-code/); - // Host token persisted before navigation. await expect - .poll(() => page.evaluate(() => localStorage.getItem("sn_host_owner_key_v2")), { timeout: 5_000 }) + .poll(() => page.evaluate(() => localStorage.getItem("sn_host_owner_key_v2")), { + timeout: 5_000, + }) .toContain("hk1:"); - // Still on the landing — apex stays honestly "not live" until they enter. expect(page.url()).toBe("https://scratchnode.live/"); - // "Enter your room →" carries the host into their live room. await page.click("#share-enter-btn"); await expect.poll(() => page.url(), { timeout: 5_000 }).toContain("/e/launch-room"); }); @@ -502,7 +2401,6 @@ test.describe("ScratchNode live route honesty", () => { () => JSON.parse(localStorage.getItem("__snCreatedEventArgs") || "{}").roomCode ?? null, ); expect(sentRoomCode).toBeNull(); - // Share moment appears with the auto-generated code; "Enter your room →" navigates. await expect(page.locator("#share-moment")).toHaveAttribute("data-open", "true"); await expect(page.locator("#invite-card-code")).toHaveText("LAUNCH"); await page.click("#share-enter-btn"); @@ -510,7 +2408,6 @@ test.describe("ScratchNode live route honesty", () => { }); test("share moment: copy link + invite text + share buttons are wired", async ({ page }) => { - // Persona: host who just created a room and wants to invite friends. await fulfillScratchNodePage(page); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await page.goto("https://scratchnode.live/", { waitUntil: "domcontentloaded" }); @@ -519,23 +2416,18 @@ test.describe("ScratchNode live route honesty", () => { await page.click("#landing-create-btn"); await expect(page.locator("#share-moment")).toHaveAttribute("data-open", "true"); - // The reason-to-share copy + the screenshot-worthy invite card. Scoped to - // #share-moment — the wiki-live recap moment reuses the same classes. await expect(page.locator("#share-moment .share-moment__sub")).toContainText("shared memory"); await expect(page.locator("#share-moment .invite-card__tag")).toContainText("remembers everything"); - // Copy link writes the room URL to the clipboard + flashes confirmation. await page.click("#share-link-copy"); await expect(page.locator("#share-link-copy")).toHaveAttribute("data-copied", "true"); expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("/e/launch-room"); - // Copy invite text carries the reason to share + the link. await page.click("#share-invite-copy"); const invite = await page.evaluate(() => navigator.clipboard.readText()); expect(invite).toContain("Launch Party"); expect(invite).toContain("/e/launch-room"); - // Text + Email deep links carry the invite + URL. await expect(page.locator("#share-btn-text")).toHaveAttribute("href", /^sms:.*launch-room/); await expect(page.locator("#share-btn-email")).toHaveAttribute("href", /^mailto:.*launch-room/); }); @@ -618,11 +2510,9 @@ test.describe("ScratchNode live route honesty", () => { timeout: 6_000, }); await expect(page.locator(".landing-room-title")).toHaveText("Open Office Hours"); - // "● N inside" presence cue (live dot + count), not a bare "active" label. await expect(page.locator(".landing-room-meta")).toContainText("6 inside"); await expect(page.locator(".landing-room-meta")).toContainText("OFFICE"); await expect(page.locator(".landing-room-meta .dot-inside")).toBeVisible(); - // Open-policy room → one-tap navigate (), no approval needed. await expect(page.locator(".landing-room-join")).toHaveText("Join now"); await expect(page.locator(".landing-room-join")).toHaveAttribute("href", "/e/open-office-hours"); }); @@ -653,12 +2543,10 @@ test.describe("ScratchNode live route honesty", () => { timeout: 6_000, }); const join = page.locator(".landing-room-join"); - // Request-policy room → a real