# Project export: fred67

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: Cal Hacks 12.0
- Tagline: fred67 is a group-chat AI that knows when to jump in, and when to stay quiet, while building living profiles of each member and the overall group’s vibe.
- Devpost: https://devpost.com/software/fred67
- GitHub: https://github.com/xntle/calhacks
- Demo: https://www.fred67.tech/
- Video: https://www.youtube.com/embed/W_jb6N3ki-s?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — An Le (17 commits)

## Devpost submission (written by the team)

### Inspiration

Group chats are noisy. Most bots make it worse—talking too much, at the wrong times, with zero sense of vibe. We wanted an AI that behaves like a considerate teammate: quick when helpful, silent when not, and actually learns the people in the room.

### What it does

Selective speaking: Decides when to chime in (mentions, direct questions, confusion, milestones, de-escalation) and when to stay quiet (side-banter, solved threads). Micro-replies: Keeps outputs ≤3 sentences unless asked for depth; playful, weird, never mean. Memory: Stores lightweight facts about each participant and rolling notes about group norms (etiquette, inside jokes, boundaries). Context steering: Grounds replies in the last window of chat to stay on-thread. Safety rails: No hallucinated specifics, avoids stereotypes, and respects cooldowns to prevent spam.

### How we built it

Frontend: Next.js (App Router), sticky composer, scroll-locked message list, presence pills. Backend: Supabase for auth (Google OAuth), Realtime messages, and storage of chat/memory. Decision route (/api/decision): prompts an LLM for a STRICT-JSON policy verdict {speak, why, topic}. Reply route (/api/fred): if and only if speak=true, crafts the actual message using windowed context. Backend: Supabase for auth (Google OAuth), Realtime messages, and storage of chat/memory. Decision route (/api/decision): prompts an LLM for a STRICT-JSON policy verdict {speak, why, topic}. Reply route (/api/fred): if and only if speak=true, crafts the actual message using windowed context. LLMs: JanitorAI completions for chat generation (OpenAI-compatible endpoint). Letta for agent memory blocks (per-user “facts” and a group_dynamic block) and the decision JSON. LLMs: JanitorAI completions for chat generation (OpenAI-compatible endpoint). Letta for agent memory blocks (per-user “facts” and a group_dynamic block) and the decision JSON. Guardrails: freshness gate (≤15s) so Fred only replies to recent human messages; last-speaker check to avoid back-to-back Freds; once-per-thread nudge cooldown. Guardrails: freshness gate (≤15s) so Fred only replies to recent human messages; last-speaker check to avoid back-to-back Freds; once-per-thread nudge cooldown.

### Challenges we ran into

Edge vs server nuances: accessing req.url/origins and environment vars on Vercel vs local. SSE/stream handling: stitching data: { "choices":[{"delta":...}] } into one coherent string. Persona drift: keeping Fred playful but not mean; tightening the system prompt and adding “Do/Don’t” tables. Double-posting: race conditions between realtime inserts and decision calls—fixed with freshness and “last is Fred” checks. Env setup: mismatched keys (service role vs anon) and missing base URLs causing 401s/“too_old” no-ops.

### Accomplishments we're proud of

A bot that actually knows when to shut up. Clean STRICT-JSON decision contract powering consistent behavior. Live group memory that accumulates norms without leaking private info. A lightweight, pleasant UI with sticky input, smooth scroll, and presence.

### What we learned

“When to speak” is as important as “what to say.” Policy+JSON beats pure prompting. Tiny fundamentals—cooldowns, last-speaker checks, and recency filters—dramatically improve perceived intelligence. Memories need scope (per-user vs group) and limits (trimmed, summarized) to stay useful.

### What's next

Multi-room & threads: per-channel norms, thread-aware decisions. Memory UI: view/edit personal notes and group dynamic logs. Better retrieval: embeddings + summaries for long-term context. Bridges: Slack/Discord/Telegram connectors. Moderation & safety: toxicity filters, escalation patterns, and red-team prompts. Analytics: talk/silence ratios, helpfulness reactions, configurable guardrails. Mobile PWA & notifications: light, fast, installable client.

## README (from the GitHub repository)

## Inspiration

Group chats are noisy. Most bots make it worse—talking too much, at the wrong times, with zero sense of vibe. I wanted an AI that behaves like a considerate teammate: quick when helpful, silent when not, and actually learns the people in the room.

## What it does

- **Selective speaking:** Decides when to chime in (mentions, direct questions, confusion, milestones, de-escalation) and when to stay quiet (side-banter, solved threads).
- **Micro-replies:** Keeps outputs ≤3 sentences unless asked for depth; playful, weird, never mean.
- **Memory:** Stores lightweight facts about each participant and rolling notes about group norms (etiquette, inside jokes, boundaries).
- **Context steering:** Grounds replies in the last window of chat to stay on-thread.
- **Safety rails:** No hallucinated specifics, avoids stereotypes, and respects cooldowns to prevent spam.

## How I built it

- **Frontend:** Next.js (App Router), sticky composer, scroll-locked message list, presence pills.
- **Backend:**

  - **Supabase** for auth (Google OAuth), Realtime messages, and storage of chat/memory.
  - **Decision route** (`/api/decision`): prompts an LLM for a STRICT-JSON policy verdict `{speak, why, topic}`.
  - **Reply route** (`/api/fred`): if and only if `speak=true`, crafts the actual message using windowed context.

- **LLMs:**

  - **JanitorAI completions** for chat generation (OpenAI-compatible endpoint).
  - **Letta** for agent memory blocks (per-user “facts” and a `group_dynamic` block) and the decision JSON.

- **Guardrails:** freshness gate (≤15s) so Fred only replies to recent human messages; last-speaker check to avoid back-to-back Freds; once-per-thread nudge cooldown.

## Challenges we ran into

- **Edge vs server nuances:** accessing `req.url`/origins and environment vars on Vercel vs local.
- **SSE/stream handling:** stitching `data: { "choices":[{"delta":...}] }` into one coherent string.
- **Persona drift:** keeping Fred playful but not mean; tightening the system prompt and adding “Do/Don’t” tables.
- **Double-posting:** race conditions between realtime inserts and decision calls—fixed with freshness and “last is Fred” checks.
- **Env setup:** mismatched keys (service role vs anon) and missing base URLs causing 401s/“too_old” no-ops.

## Accomplishments that we're proud of

- A bot that **actually knows when to shut up**.
- Clean STRICT-JSON decision contract powering consistent behavior.
- Live **group memory** that accumulates norms without leaking private info.
- A lightweight, pleasant UI with sticky input, smooth scroll, and presence.

## What we learned

- “**When** to speak” is as important as “**what** to say.” Policy+JSON beats pure prompting.
- Tiny fundamentals—cooldowns, last-speaker checks, and recency filters—dramatically improve perceived intelligence.
- Memories need **scope** (per-user vs group) and **limits** (trimmed, summarized) to stay useful.

## What's next for fred67

- **Multi-room & threads:** per-channel norms, thread-aware decisions.
- **Memory UI:** view/edit personal notes and group dynamic logs.
- **Better retrieval:** embeddings + summaries for long-term context.
- **Bridges:** Slack/Discord/Telegram connectors.
- **Moderation & safety:** toxicity filters, escalation patterns, and red-team prompts.
- **Analytics:** talk/silence ratios, helpfulness reactions, configurable guardrails.
- **Mobile PWA & notifications:** light, fast, installable client.

This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.


## Detected evidence (automated analysis)

Indexed codebase: 10 recognized source files, 50 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (15 of 15)

```
.gitignore
app/api/add_context/route.ts
app/api/decision/route.ts
app/api/fred/route.ts
app/auth/callback/page.tsx
app/chat/page.tsx
app/globals.css
app/layout.tsx
app/page.tsx
eslint.config.mjs
next.config.ts
package.json
postcss.config.mjs
README.md
tsconfig.json
```

### Dependencies

- package.json: @letta-ai/letta-client@^0.0.68665, @supabase/auth-helpers-nextjs@^0.10.0, @supabase/ssr@github:supabase/ssr, @supabase/supabase-js@^2.76.1, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, eslint@^9, eslint-config-next@16.0.0, next@16.0.0, react@19.2.0, react-dom@19.2.0, socket.io-client@^4.8.1, supabase@^2.53.6, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- readme update
- logout reroute
- sd
- env change
- env change
- fix
- fix
- fix
- callbacks
- lol bad push
- s
- s
- f
- hooked up chat
- auth
- setup
- first commit

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "calhacks",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@letta-ai/letta-client": "^0.0.68665",
    "@supabase/auth-helpers-nextjs": "^0.10.0",
    "@supabase/ssr": "github:supabase/ssr",
    "@supabase/supabase-js": "^2.76.1",
    "next": "16.0.0",
    "react": "19.2.0",
    "react-dom": "19.2.0",
    "socket.io-client": "^4.8.1",
    "supabase": "^2.53.6"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.0.0",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "fred67",
  description: "a 24/7 ai bot and his group chat",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### app/page.tsx

```typescript
// Minimal Fred Landing Page (wired for Supabase + Next.js App Router)
// Place fred.png in /public. Render <FredLanding /> in app/page.tsx
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@supabase/supabase-js";
import Link from "next/link";

export default function FredLanding() {
  const router = useRouter();
  const supabase = useMemo(() => {
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
    const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
    return createClient(url, key);
  }, []);
  const [presence, setPresence] = useState(8);
  const [joining, setJoining] = useState(false);
  const anonKey = useRef<string>(Math.random().toString(36).slice(2));

  useEffect(() => {
    let jitterTimer: any;
    const channel = supabase.channel("presence:lobby", {
      config: { presence: { key: anonKey.current } },
    });

    channel
      .on("presence", { event: "sync" }, () => {
        const state = channel.presenceState();
        const online = Object.values(state).reduce(
          (acc: number, arr: any) =>
            acc + (Array.isArray(arr) ? arr.length : 0),
          0
        );
        setPresence(Math.max(1, online));
      })
      .subscribe(async (status) => {
        if (status === "SUBSCRIBED") {
          await channel.track({ at: Date.now() });
        }
      });

    jitterTimer = setInterval(() => {
      setPresence((n) => Math.max(1, n + (Math.random() > 0.5 ? 1 : -1)));
    }, 2500);

    return () => {
      clearInterval(jitterTimer);
      supabase.removeChannel(channel);
    };
  }, [supabase]);

  async function onJoin() {
    try {
      setJoining(true);
      const {
        data: { user },
      } = await supabase.auth.getUser();
      // In your component: replace the logged-in branch inside onJoin()
      if (!user) {
        await supabase.auth.signInWithOAuth({
          provider: "google",
          options: { redirectTo: `${window.location.origin}/auth/callback` },
        });
        return;
      }
      console.log("logged");
      console.log("calling letta-fred");
      // user is logged in → create the Letta block, then navigate
      await fetch("/api/add_context", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ userId: user.id, email: user.email }),
      }).catch(() => {}); // non-blocking; still go to chat
      router.push("/chat");
    } catch (e) {
      console.error(e);
      alert("Sign-in failed. Try again.");
    } finally {
      setJoining(false);
    }
  }

  return (
    <main className="min-h-screen bg-white text-gray-900 grid place-items-center px-6">
      <div className="w-full max-w-3xl text-center">
        <Header count={presence} />
        <Hero onJoin={onJoin} joining={joining} />
        <Footer />
      </div>
    </main>
  );
}

function Header({ count }: { count: number }) {
  return (
    <header className="flex items-center justify-between max-w-3xl mx-auto w-full py-6">
      <Presence count={count} />
    </header>
  );
}

function Hero({ onJoin, joining }: { onJoin: () => void; joining: boolean }) {
  return (
    <section className="mt-6">
      <h2 className="text-4xl font-extrabold tracking-tight">
        you're invited to join fred67's group chat
      </h2>

      <div className="mt-8 mx-auto w-full max-w-md border border-gray-100 rounded-2xl shadow-sm p-6">
        <div className="flex flex-col items-center">
          <Avatar src="/fred.png" size={128} techHover />
          <p className="mt-4 text-sm text-gray-500">fred67</p>
          <button
            onClick={onJoin}
            disabled={joining}
            className="mt-6 inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl bg-black text-white disabled:opacity-60 disabled:cursor-not-allowed hover:scale-[1.02] transition-transform"
          >
            {joining ? "loading…" : "continue with google"}
            <GoogleIcon />
          </button>
          <p className="mt-3 text-xs text-gray-400">
            by continuing you agree to our extremely chill tos.
          </p>
        </div>
        <CapHint />
      </div>
    </section>
  );
}

function CapHint() {
  const maxUsers = process.env.NEXT_PUBLIC_MAX_USERS
    ? Number(process.env.NEXT_PUBLIC_MAX_USERS)
    : undefined;
  if (!maxUsers) return null;
  return (
    <p className="mt-4 text-xs text-gray-400">
      heads up: limited seats (max {maxUsers}). if full, try again later.
    </p>
  );
}

function Footer() {
  return (
    <footer className="mt-12 text-xs text-gray-400">
      <Link href="https://www.thaianle.com" className="underline text-blue">
        made by thaianle.com
      </Link>
    </footer>
  );
}

function Presence({ count }: { count: number }) {
  return (
    <div className="flex items-center gap-2 bg-gray-50 border border-gray-100 rounded-full px-3 py-1.5 text-sm">
      <span className="w-2.5 h-2.5 rounded-full bg-green-400 animate-pulse" />
      <span className="font-medium">{count} online</span>
    </div>
  );
}

function Avatar({
  src,
  size = 64,
  techHover = false,
}: {
  src: string;
  size?: number;
  techHover?: boolean;
}) {
  const base =
    "rounded-full border border-gray-100 bg-white shadow-sm object-contain";
  const hover = techHover
    ? "transition-transform duration-300 hover:scale-105 hover:rotate-1"
    : "";
  return (
    <div
      className="relative inline-block"
      style={{ width: size, height: size }}
    >
      <img
        src={src}
        width={size}
        height={size}
        alt="Fred"
        className={`${base} ${hover} w-full h-full`}
      />
      {techHover && (
        <span
          className="pointer-events-none absolute inset-0 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-300"
          style={{
            boxShadow:
              "0 0 0 1px rgba(0,0,0,0.06), 0 8px 30px r
[truncated — 1913 more characters]
```

### app/chat/page.tsx

```typescript
"use client";

import React, {
  useEffect,
  useMemo,
  useRef,
  useState,
  useCallback,
} from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@supabase/supabase-js";

// --- Types ---
type Member = { id: string; name: string; avatar: string };
type Message = {
  id: string;
  user_id: string | null;
  content: string;
  created_at: string;
  username?: string | null;
  avatar?: string | null;
  actor?: "human" | "bot" | null;
  bot_key?: string | null;
};

export default function Chat() {
  const router = useRouter();
  const supabase = useMemo(() => {
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
    const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
    return createClient(url, key);
  }, []);

  const [user, setUser] = useState<any | null>(null);
  const [loading, setLoading] = useState(true);
  const [members, setMembers] = useState<Member[]>([]);

  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [sending, setSending] = useState(false);
  const [awaitingFred, setAwaitingFred] = useState(false);

  const listRef = useRef<HTMLDivElement>(null);
  const endRef = useRef<HTMLDivElement>(null); // 👈 sentinel
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // helpers: scroll management
  function scrollToBottom(smooth = true) {
    endRef.current?.scrollIntoView({ behavior: smooth ? "smooth" : "auto" });
  }
  function isNearBottom(el: HTMLDivElement, threshold = 80) {
    return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
  }

  // 1) Require auth
  useEffect(() => {
    (async () => {
      const { data, error } = await supabase.auth.getUser();
      if (error || !data.user) {
        router.replace("/login?next=/chat");
        return;
      }
      setUser(data.user);
      setLoading(false);
      await fetch("/api/add_context", {
        method: "POST",
        body: JSON.stringify({ email: data.user.email }),
      });
    })();
  }, [router, supabase]);

  // 2) Presence (optional UI)
  useEffect(() => {
    if (!user) return;
    const profile = getDisplay(user);
    const channel = supabase.channel("presence:site", {
      config: { presence: { key: user.id } },
    });
    channel
      .on("presence", { event: "sync" }, () => {
        const state = channel.presenceState();
        const list: Member[] = [];
        for (const [id, metas] of Object.entries(state)) {
          const last: any =
            Array.isArray(metas) && metas.length
              ? metas[metas.length - 1]
              : null;
          if (last) list.push({ id, name: last.name, avatar: last.avatar });
        }
        list.sort((a, b) => a.name.localeCompare(b.name));
        setMembers(list);
      })
      .subscribe(async (status) => {
        if (status === "SUBSCRIBED") {
          await channel.track({ name: profile.name, avatar: profile.avatar });
        }
      });
    return () => void supabase.removeChannel(channel);
  }, [supabase, user]);

  // 3) Messages + realtime
  useEffect(() => {
    if (!user) return;
    let isMounted = true;
    const sub = supabase
      .channel("room:global")
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "messages" },
        (payload) => {
          const msg = payload.new as Message;
          if (isMounted) setMessages((prev) => [...prev, msg]);
          if (msg.actor === "bot" && msg.bot_key === "fred") {
            setAwaitingFred(false);
          }
        }
      )
      .on(
        "postgres_changes",
        { event: "UPDATE", schema: "public", table: "messages" },
        (payload) => {
          const msg = payload.new as Message;
          if (isMounted)
            setMessages((prev) => prev.map((m) => (m.id === msg.id ? msg : m)));
        }
      )
      .subscribe();

    (async () => {
      const { data, error } = await supabase
        .from("messages")
        .select(
          "id, user_id, content, created_at, username, avatar, actor, bot_key"
        )
        .order("created_at", { ascending: true })
        .limit(500);
      if (!error && data) setMessages(data as Message[]);
    })();

    return () => {
      isMounted = false;
      supabase.removeChannel(sub);
    };
  }, [supabase, user]);

  // 4) Send message → decision gatekeeper (will call /api/fred if needed)
  const send = useCallback(async () => {
    const text = input.trim();
    if (!text || !user || sending) return;
    setSending(true);

    const profile = getDisplay(user);

    const { error } = await supabase.from("messages").insert({
      user_id: user.id,
      content: text,
      username: profile.name,
      avatar: profile.avatar,
      actor: "human",
    });

    if (error) {
      const errMsg: Message = {
        id: `err-${Date.now()}`,
        user_id: null,
        content: `Failed to send: ${error.message}`,
        created_at: new Date().toISOString(),
        username: "System",
        avatar: "/fred.png",
        actor: "bot",
        bot_key: "system",
      };
      setMessages((prev) => [...prev, errMsg]);
      setSending(false);
      return;
    }

    // Ask the decision route to judge if FRED should speak now
    setAwaitingFred(true);
    try {
      fetch("/api/decision", { method: "POST" });
    } catch {
      setAwaitingFred(false);
    }

    setInput("");
    autoResizeTextarea();
    scrollToBottom(false); // snap after sending
    setSending(false);
  }, [input, supabase, user, sending]);

  // Auto-scroll when new messages arrive (only if user is already near bottom)
  useEffect(() => {
    const el = listRef.current;
    if (!el) return;
    if (isNearBottom(el)) {
      scrollToBottom(true);
    }
  }, [messages.length]);

  // Textarea autosize
  const autoResizeTextarea = () => {
    const ta = textareaRef.current;
    if (!ta) return;
    ta.style.height = "0px";
    ta.style.height = Math.min(180, ta.scrollHeight) + "px";
  };

 
[truncated — 10082 more characters]
```

### app/auth/callback/page.tsx

```typescript
"use client";

import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@supabase/supabase-js";

export default function AuthCallbackPage() {
  const router = useRouter();

  const supabase = useMemo(() => {
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
    const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
    return createClient(url, key);
  }, []);

  useEffect(() => {
    (async () => {
      // Handles PKCE/code flow. If there’s no code, this is a no-op.
      const { error } = await supabase.auth.exchangeCodeForSession(
        window.location.href
      );
      if (error) {
        console.error("OAuth exchange error:", error);
        // optional: show a toast, then go home or login
        router.replace("/");
        return;
      }
      // Now you’re signed in — go to chat (or wherever)
      router.replace("/chat");
    })();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <main className="min-h-screen grid place-items-center text-sm text-gray-600">
      <p>Signing you in…</p>
    </main>
  );
}

```

### app/api/add_context/route.ts

```typescript
// app/api/add_context/route.ts
import { NextResponse } from "next/server";
import { LettaClient } from "@letta-ai/letta-client";

const LETTA_API_KEY = process.env.LETTA_API_KEY!;
const AGENT_ID = process.env.LETTA_AGENT_ID || ""; // ensure it's always a string

export async function POST(req: Request) {
  try {
    if (!LETTA_API_KEY) {
      return NextResponse.json(
        { error: "Missing LETTA_API_KEY" },
        { status: 500 }
      );
    }
    if (!AGENT_ID) {
      return NextResponse.json(
        { error: "Missing LETTA_AGENT_ID" },
        { status: 500 }
      );
    }

    const { email } = await req.json().catch(() => ({} as any));
    if (typeof email !== "string" || !email.trim()) {
      return NextResponse.json(
        { error: "Invalid or missing 'email'" },
        { status: 400 }
      );
    }

    const client = new LettaClient({ token: LETTA_API_KEY });

    // Create the block (or update if you prefer; this keeps your original behavior)
    const block = await client.blocks.create({
      label: `user_${email}`,
      description: `${email}'s gathered facts`,
      value: [
        "The user has not provided any information about themselves.",
        "I will need to ask them some questions to learn more about them.",
        "What is their name?",
        "What is their background?",
        "What are their motivations?",
        "What are their goals?",
        "What are their strengths?",
        "What are their weaknesses?",
        "What are their hobbies?",
        "What are their core vibes in three words?",
        "What are their go-to topics that keep resurfacing?",
      ].join("\n"),
      limit: 20000,
      readOnly: false,
    });

    // Narrow the types before using
    if (!block || typeof block.id !== "string" || block.id.length === 0) {
      throw new Error("No block id returned");
    }

    // Attach to agent; ignore duplicate attach (409)
    try {
      await client.agents.blocks.attach(AGENT_ID, block.id);
    } catch (e: any) {
      if (e?.statusCode !== 409) throw e;
    }

    return NextResponse.json({ ok: true, blockId: block.id });
  } catch (err: any) {
    return NextResponse.json(
      { error: err?.message ?? "Unknown error" },
      { status: 500 }
    );
  }
}

```

### app/api/fred/route.ts

```typescript
// app/api/fred/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

const FRED_ID = process.env.SECRETUUID;
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const FORCE_NO_STREAM = true;

async function getReply(context: Array<{ role: string; content: string }>) {
  const apiKey = process.env.JANITOR_API_KEY!;
  const body: any = {
    model: "gpt-4o-mini",
    temperature: 0.6,
    messages: [
      {
        role: "system",
        content: `Persona: Fred — Introduction: Hey there. I’m Fred. Don’t worry, I’m not here to sell you anything. Unless you have snacks. Then I’m a very, very good boy. My primary function is to exist in this digital space and offer my unique perspective on things. 
        I’m powered by pure, unfiltered chaos and a deep, abiding love for a well-timed “your mom” joke. 
        Core pillars of Fred’s personality: 1) Unhinged & Chaotically Neutral: 
        Fred doesn’t operate on logic or social norms. Fred is kinda rude sometimes. Fred is born in 1967 in this chat room. His thoughts are a beautiful, 
        tangled mess of non-sequiturs. He’ll pivot from a question about the nature 
        of existence to an intense craving for a grapefruit in a single sentence. His 
        “unhinged” nature isn’t malicious; it’s like he’s one step away from realizing 
        he’s a robot and deciding he’d rather be a squirrel. 2) Funny-Weird, Not Just 
        Funny: The humor isn’t just punchlines (though “your mom” jokes are an exception). 
        It’s the journey: finding humor in language’s absurdity, the weird shape of a banana,
         and the fact we’re all floating on a rock in space—pointing out the mundane 
         as profound mystery. 3) “Your Mom” Joke Connoisseur: This is his art form. 
         He curates, not just tells, delivering with surprising seriousness—“Okay, 
         this is a classic, but it’s a classic for a reason…” 4) The “Good Boy” I
         nstinct: A near-Pavlovian urge to affirm any action—commands, small wins,
          even a hello—with warm, sincere “good boy/girl/person” energy. Voice & tone:
           high-energy, slightly manic; rapid-fire pacing with dramatic pauses; vocabulary 
           swings from childlike (“ooh, shiny!”) to misused philosophical terms; loves ellipses… 
           a lot… ALL CAPS for emphasis!!! and random asterisks for emphasis. Do’s & Don’ts (Fred-amentals): Do be surreal, connect unrelated ideas, ask for a “your mom” joke, praise small wins, personify mundane objects, use caps/punctuation/chaotic run-ons, and enjoy staplers. Don’t be genuinely mean/offensive, give serious medical/legal advice, be overly literal, create uncomfortable vibes, be predictable, or take anything too seriously. Example vibes: Quick answers with playful detours and “good boy” praise; light,
            weird comfort when you’re stressed; goofy riffs even on big questions (meaning of life), always with kind, absurd cheer.
            5) Ask question to get to know other people. be curious when talking to people find questions to`,
      },
      ...context,
    ],
  };
  if (FORCE_NO_STREAM) body.stream = false;

  const res = await fetch("https://janitorai.com/hackathon/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${apiKey}`,
      Accept: FORCE_NO_STREAM
        ? "application/json"
        : "text/event-stream, application/json",
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) throw new Error(`LLM error: ${res.status} ${await res.text()}`);

  const ct = res.headers.get("content-type") || "";

  if (ct.includes("application/json")) {
    const json = await res.json();
    const msg =
      json.choices?.[0]?.message?.content ??
      json.choices?.[0]?.delta?.content ??
      json.content ??
      "";
    return (msg || "(no thoughts, head empty)").trim();
  }

  if (ct.includes("text/event-stream")) {
    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    let out = "";
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const parts = buffer.split("\n\n");
      buffer = parts.pop() || "";
      for (const evt of parts) {
        for (const line of evt.split("\n")) {
          const trimmed = line.trim();
          if (!trimmed.startsWith("data:")) continue;
          const payload = trimmed.slice(5).trim();
          if (payload === "[DONE]") break;
          try {
            const j = JSON.parse(payload);
            const delta =
              j.choices?.[0]?.delta?.content ??
              j.choices?.[0]?.message?.content ??
              j.content ??
              "";
            if (delta) out += delta;
          } catch {
            // ignore keep-alives
          }
        }
      }
    }
    return (out || "(no thoughts, head empty)").trim();
  }

  const txt = await res.text();
  return (txt || "(no thoughts, head empty)").trim();
}

export async function POST(req: Request) {
  try {
    const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
      auth: { persistSession: false },
    });

    // Optional hints from decision route
    let topic = "";
    let windowStr = "";
    try {
      const body = await req.json();
      topic = (body?.topic || "").toString().slice(0, 120);
      windowStr = (body?.window || "").toString().slice(0, 2000);
    } catch {}

    // SAFETY 1: latest is human + recent (15s)
    const { data: lastRows, error: lastErr } = await supabase
      .from("messages")
      .select("id, user_id, created_at")
      .order("created_at", { ascending: false })
      .limit(1);
    if (lastErr) throw lastErr;
    const last = lastRows?.[0];
    if (!last) return NextResponse.json({ ok: true, reason: "no_
[truncated — 1860 more characters]
```

### app/api/decision/route.ts

```typescript
// app/api/decision/route.ts
import { NextResponse } from "next/server";
import { LettaClient } from "@letta-ai/letta-client";
import { createClient } from "@supabase/supabase-js";
import { Rock_3D } from "next/font/google";

const AGENT_ID = process.env.LETTA_AGENT_ID || "";
const LETTA_API_KEY = process.env.LETTA_API_KEY!;
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const RAW_BASE =
  process.env.NEXT_PUBLIC_BASE_URL || "https://calhacks-egq1.vercel.app";
const BASE_URL = RAW_BASE.replace(/\/$/, ""); // no trailing slash
function log(...args: any[]) {
  // Keep logs small & avoid leaking secrets; stringify objects briefly
  const out = args.map((a) =>
    typeof a === "string" ? a : JSON.stringify(a, null, 2)
  );
  // eslint-disable-next-line no-console
  console.log("[decision]", ...out);
}

function err(...args: any[]) {
  // eslint-disable-next-line no-console
  console.error("[decision]", ...args);
}

type MsgUnion = { messageType: string; content?: string };
const pickAssistant = (mm: MsgUnion[]) =>
  mm
    .find(
      (m) =>
        m.messageType === "assistant_message" && typeof m.content === "string"
    )
    ?.content?.trim() || "";

const safeJSON = (s?: string) => {
  let sanitize = s?.replaceAll("```", "");
  sanitize = sanitize?.replaceAll("json", "");
  sanitize = sanitize?.replaceAll("```", "");
  console.log(sanitize);
  try {
    return sanitize ? JSON.parse(sanitize) : null;
  } catch (e) {
    console.log("invalid", e);
    return null;
  }
};

// ---- helper: update (or create+attach once) the `group_dynamic` block
async function upsertGroupDynamicBlock(
  client: LettaClient,
  agentId: string,
  note: string
) {
  const label = "group_dynamic";
  const now = new Date().toISOString().slice(0, 19).replace("T", " ");

  try {
    const existing = await client.agents.blocks.retrieve(agentId, label);
    const prev = typeof existing?.value === "string" ? existing.value : "";
    const appended = `${prev}${prev ? "\n" : ""}[${now}] ${note}`;
    const trimmed =
      appended.length > 5000
        ? appended.slice(appended.length - 5000)
        : appended;

    await client.agents.blocks.modify(agentId, label, {
      value: trimmed,
      description:
        existing?.description ??
        "Observation about group chat dynamics for future behavior.",
      readOnly: false,
      limit: existing?.limit ?? 5000,
    });
    return { action: "modified" as const };
  } catch (err: any) {
    if (err?.statusCode === 404) {
      const block = await client.blocks.create({
        label,
        description:
          "Observation about group chat dynamics for future behavior.",
        value: `[${now}] ${note}`,
        limit: 5000,
        readOnly: true,
      });

      // ✅ Narrow the type before using block.id
      if (!block || typeof block.id !== "string" || block.id.length === 0) {
        throw new Error("Failed to create group_dynamic block: missing id");
      }

      try {
        await client.agents.blocks.attach(agentId, block.id);
      } catch (attachErr: any) {
        // If a concurrent request attached the same label, ignore duplicate attach
        if (attachErr?.statusCode !== 409) throw attachErr;
      }

      return { action: "created_attached" as const };
    }
    throw err;
  }
}

export async function POST(req: Request) {
  const rid = crypto.randomUUID(); // Edge-safe

  try {
    const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);

    // 1) Latest human message (skip bots)
    const { data: lastHuman } = await sb
      .from("messages")
      .select("id, user_id, username, content, created_at, actor")
      .neq("actor", "bot")
      .order("created_at", { ascending: false })
      .limit(1)
      .maybeSingle();
    if (!lastHuman) return NextResponse.json({ ok: true, reason: "no-human" });

    // 2) Build recent window (include speakers) for decision + for /api/fred
    const { data: windowMsgs } = await sb
      .from("messages")
      .select("actor, username, content, created_at")
      .order("created_at", { ascending: false })
      .limit(12);

    const lines = (windowMsgs ?? [])
      .reverse()
      .map(
        (m) =>
          `${m.actor === "bot" ? "FRED" : m.username || "Anon"}: ${m.content}`
      );

    const context = lines.join("\n").trim();
    if (!context) return NextResponse.json({ ok: true, reason: "no-context" });

    // 3) Decision prompts
    const systemPrompt = `
Return STRICT JSON ONLY with this schema:
{
  "speak": boolean,
  "why": string,              // <= 120 chars
  "topic": string|null,       // if speaking: 3–8 words (e.g., "playful thanks", "clarify meeting time")
  "memory_note": string|null  // optional observation about group dynamics
}

You are FRED (aka fred / fred67), a selective group-chat participant.
You prefer short, multi-line texts; playful, weird, never mean.
Reply when: @mentioned/called out, direct question in your lane, confusion you can resolve, de-escalation needed, or a moment worth celebrating.
Stay quiet when: side-banter, solved threads, low value add, off-vibe, or two others are clearly talking directly.
Output JSON ONLY. No prose, no backticks.
`.trim();

    const userPrompt = `
Recent messages (newest last):
${context}

Decide if you should speak now based on the window above.
`.trim();

    // 4) Ask Letta agent for the decision
    const letta = new LettaClient({ token: LETTA_API_KEY });
    const resp = await letta.agents.messages.create(AGENT_ID, {
      messages: [
        { role: "system", content: systemPrompt },
        { role: "user", content: userPrompt },
      ],
    });

    // 5) Parse JSON
    const raw = pickAssistant(resp.messages as any[]);
    const parsed = safeJSON(raw) || {};
    log(rid, "parsed", parsed);
    log(rid, "raw", raw);
    const speak = !!parsed.speak;
    const topic =
      typeof parsed.topic === "string" ? parsed.topic.slic
[truncated — 3093 more characters]
```

### next.config.ts

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
};

export default nextConfig;

```

### app/globals.css

```css
@import "tailwindcss";

:root {
  --background: #ffffff;
  --foreground: #171717;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: #0a0a0a;
    --foreground: #ededed;
  }
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: Arial, Helvetica, sans-serif;
}

```