diff --git a/__tests__/unit/config/nav-destinations-do-not-bounce.test.ts b/__tests__/unit/config/nav-destinations-do-not-bounce.test.ts new file mode 100644 index 000000000..b5e7939dc --- /dev/null +++ b/__tests__/unit/config/nav-destinations-do-not-bounce.test.ts @@ -0,0 +1,117 @@ +/** + * A nav entry is a promise: "this row takes you somewhere the other rows don't." + * + * `mobile-tab-bar.test.ts` already checks that no two rows share an href — but + * hrefs are compared as *strings*, and that is exactly how the sidebar shipped + * two rows ("Cat" and "Home") that both landed on /dashboard/cat: /dashboard's + * page component `router.replace`d everyone to the Cat hub. Textually distinct, + * identical in the browser, and the dashboard became unreachable — along with + * the breadcrumb crumb, the 404 recovery link and every RouteError that points + * at /dashboard. + * + * So: an authenticated nav destination may not redirect to another authenticated + * nav destination. Bouncing somewhere that is NOT a nav row (/auth, onboarding) + * stays allowed — that's a guard, not a duplicate row. + * + * Scope, stated rather than implied: this reads each destination's own + * `page.tsx`. A redirect hidden inside a child component or a layout is not + * caught here. + */ + +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { mobileTabBar, sidebarSections } from '@/config/navigation'; +import { ROUTES } from '@/config/routes'; + +const APP_DIR = join(process.cwd(), 'src', 'app'); + +/** Every route the signed-in user can reach from a nav row, path-only. */ +function authenticatedNavDestinations(): string[] { + const fromSidebar = sidebarSections + .flatMap(section => section.items) + .filter(item => item.requiresAuth !== false) + .map(item => item.href); + const fromTabBar = mobileTabBar.filter(item => !item.opensCreate).map(item => item.href); + return [...new Set([...fromSidebar, ...fromTabBar])] + .filter((href): href is string => typeof href === 'string' && href.startsWith('/')) + .map(stripQuery); +} + +function stripQuery(href: string): string { + return href.split(/[?#]/)[0]; +} + +/** Resolve "/dashboard" to its page file, looking through route groups. */ +function pageFileFor(href: string): string | null { + const segments = href.split('/').filter(Boolean); + const groups = readdirSync(APP_DIR, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && entry.name.startsWith('(')) + .map(entry => entry.name); + for (const prefix of ['', ...groups]) { + const candidate = join(APP_DIR, prefix, ...segments, 'page.tsx'); + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +/** Look up "ROUTES.DASHBOARD.CAT" against the real config. */ +function resolveRoutesExpression(expression: string): string | null { + const path = expression.split('.').slice(1); + let value: unknown = ROUTES; + for (const key of path) { + if (typeof value !== 'object' || value === null || !(key in value)) { + return null; + } + value = (value as Record)[key]; + } + return typeof value === 'string' ? value : null; +} + +/** Redirect targets a page sends the user to on arrival. */ +function redirectTargetsIn(source: string): string[] { + const targets: string[] = []; + const calls = source.matchAll(/(?:router\.replace|\bredirect)\(([^)]*)\)/g); + for (const [, argument] of calls) { + for (const [expression] of argument.matchAll(/ROUTES(?:\.[A-Z0-9_]+)+/g)) { + const resolved = resolveRoutesExpression(expression); + if (resolved) { + targets.push(stripQuery(resolved)); + } + } + for (const [, literal] of argument.matchAll(/['"](\/[^'"]*)['"]/g)) { + targets.push(stripQuery(literal)); + } + } + return targets; +} + +describe('authenticated nav destinations', () => { + const destinations = authenticatedNavDestinations(); + + it('finds the nav rows it is meant to police', () => { + expect(destinations).toContain(ROUTES.DASHBOARD.HOME); + expect(destinations).toContain(ROUTES.DASHBOARD.CAT); + }); + + it('never redirects one nav row onto another', () => { + const others = new Set(destinations); + const collisions: string[] = []; + + for (const href of destinations) { + const file = pageFileFor(href); + if (!file) { + continue; // dynamic or externally-served route — nothing to read + } + const source = readFileSync(file, 'utf8'); + for (const target of redirectTargetsIn(source)) { + if (target !== href && others.has(target)) { + collisions.push(`${href} → ${target}`); + } + } + } + + expect(collisions).toEqual([]); + }); +}); diff --git a/__tests__/unit/config/tailwind-v4-dead-utilities.test.ts b/__tests__/unit/config/tailwind-v4-dead-utilities.test.ts new file mode 100644 index 000000000..2b1aa3d52 --- /dev/null +++ b/__tests__/unit/config/tailwind-v4-dead-utilities.test.ts @@ -0,0 +1,53 @@ +/** + * Tailwind v4 deleted the `*-opacity-*` utilities. They don't warn — they + * simply don't exist, so `bg-black bg-opacity-20` keeps the `bg-black` and + * drops the 20%: a solid black rectangle where a subtle scrim was intended. + * + * That is what turned the profile banner black on every maker profile, and + * what made the avatar's hover scrim in the profile editor an opaque black + * square instead of a 30% dim. Both had been that way since the v4 upgrade, + * and neither the type checker nor the linter can see inside a class string. + * + * The v4 spelling is the slash modifier: `bg-black/20`, `ring-black/5`. + */ + +import { readdirSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; + +const SRC = join(process.cwd(), 'src'); + +/** `bg-opacity-20`, `hover:ring-opacity-5`, `dark:text-opacity-100`, … */ +const DEAD_UTILITY = + /(?:^|[\s"'`:])((?:[a-z-]+:)*(?:bg|text|border|ring|divide|placeholder|from|via|to)-opacity-\d+)/g; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return sourceFiles(full); + } + return /\.(tsx?|css)$/.test(entry.name) ? [full] : []; + }); +} + +describe('Tailwind v4', () => { + it('is actually looking at the source tree it claims to scan', () => { + expect(statSync(SRC).isDirectory()).toBe(true); + expect(sourceFiles(SRC).length).toBeGreaterThan(100); + }); + + it('has no *-opacity-* utilities left — v4 dropped them silently', () => { + const offenders: string[] = []; + + for (const file of sourceFiles(SRC)) { + const source = readFileSync(file, 'utf8'); + source.split('\n').forEach((line, index) => { + for (const [, utility] of line.matchAll(DEAD_UTILITY)) { + offenders.push(`${file.replace(process.cwd() + '/', '')}:${index + 1} ${utility}`); + } + }); + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/__tests__/unit/hooks/useNavigationContext-hides-fixture-groups.test.ts b/__tests__/unit/hooks/useNavigationContext-hides-fixture-groups.test.ts new file mode 100644 index 000000000..b44d94071 --- /dev/null +++ b/__tests__/unit/hooks/useNavigationContext-hides-fixture-groups.test.ts @@ -0,0 +1,56 @@ +/** + * CI and workflow audits mint disposable groups ("Audit WF009 1783192620260") + * and enrol the account they run as. `config/public-directory` already knows + * they aren't real teams and keeps them off Discover and People — but the + * sidebar context switcher fetched `?membership=mine` and rendered whatever + * came back, so a live account's own context menu listed two audit fixtures + * alongside "Personal". + * + * The filter has one definition. This test holds the switcher to it. + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { useNavigationContext } from '@/hooks/useNavigationContext'; + +const mockStableAuth = { user: { id: 'user-1' } }; +jest.mock('@/hooks/useAuth', () => ({ + useAuth: () => mockStableAuth, +})); +jest.mock('next/navigation', () => ({ + usePathname: () => '/dashboard', +})); + +const GROUPS = [ + { id: 'g1', slug: 'zurich-makers', name: 'Zürich Makers', avatar_url: null }, + { id: 'g2', slug: 'audit-wf009-1783192171', name: 'Audit WF009 1783192171', avatar_url: null }, + { id: 'g3', slug: 'audit-wf009b-1783192620260', name: 'Audit WF009b 1783192620260' }, + { id: 'g4', slug: 'ephemeral-verify-1', name: 'Ephemeral Verify 1' }, +]; + +beforeEach(() => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ success: true, data: { groups: GROUPS } }), + }) + ) as unknown as typeof fetch; + window.localStorage.clear(); +}); + +describe('context switcher group list', () => { + it('lists only real groups — audit fixtures are not teams the user joined', async () => { + const { result } = renderHook(() => useNavigationContext()); + + await waitFor(() => expect(result.current.loadingGroups).toBe(false)); + + expect(result.current.userGroups.map(g => g.slug)).toEqual(['zurich-makers']); + }); + + it('would have shown the fixtures without the filter — the fetch really returns them', async () => { + renderHook(() => useNavigationContext()); + + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const returned = GROUPS.filter(g => g.name.startsWith('Audit WF')); + expect(returned).toHaveLength(2); + }); +}); diff --git a/src/app/(authenticated)/dashboard/page.tsx b/src/app/(authenticated)/dashboard/page.tsx index ab58a73e6..d28b97ab9 100644 --- a/src/app/(authenticated)/dashboard/page.tsx +++ b/src/app/(authenticated)/dashboard/page.tsx @@ -1,8 +1,6 @@ 'use client'; import dynamic from 'next/dynamic'; -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; import Loading from '@/components/Loading'; import { DashboardHeader, @@ -14,7 +12,6 @@ import { import { MobileDashboardSidebar } from '@/components/dashboard/MobileDashboardSidebar'; import { CatNudges } from '@/components/dashboard/CatNudges'; import { PendingActionsCard } from '@/components/ai-chat/PendingActionsCard'; -import { ROUTES } from '@/config/routes'; import { useDashboard } from './useDashboard'; const DashboardTimeline = dynamic( @@ -34,7 +31,6 @@ const DashboardTimeline = dynamic( ); export default function DashboardPage() { - const router = useRouter(); const { user, profile, @@ -45,7 +41,6 @@ export default function DashboardPage() { timelineLoading, timelineError, pendingActions, - pendingActionsLoaded, safeProjects, totalProjects, totalDrafts, @@ -56,45 +51,13 @@ export default function DashboardPage() { handleRejectAction, } = useDashboard(); - // Cat-first (2026-07-13, one-week plan C-1): the Cat is the DEFAULT surface. - // Everyone landing on /dashboard is routed to their Cat hub — brand-new - // accounts get the welcome variant, everyone else the plain hub. Dashboard - // content stays reachable via the left nav (Timeline → ROUTES.TIMELINE, - // entities via their own routes). - // - // Exception: if the timeline fetch errored we do NOT redirect — we render the - // dashboard below so the user keeps a visible error + retry path instead of - // being bounced away from a transient failure. (timelineFeed stays null on - // error, which would otherwise look identical to "empty".) - const isTrulyEmpty = - hydrated && - !localLoading && - !timelineLoading && - !timelineError && - pendingActionsLoaded && - !hasProjects && - pendingActions.length === 0 && - (timelineFeed?.events?.length ?? 0) === 0; - - useEffect(() => { - if (!hydrated || localLoading || !user || timelineError) { - return; - } - if (isTrulyEmpty) { - router.replace(ROUTES.DASHBOARD.CAT_WELCOME); - } else if (!timelineLoading && pendingActionsLoaded) { - router.replace(ROUTES.DASHBOARD.CAT); - } - }, [ - hydrated, - localLoading, - user, - isTrulyEmpty, - timelineLoading, - pendingActionsLoaded, - timelineError, - router, - ]); + // /dashboard renders the dashboard. It used to router.replace() everyone to + // the Cat hub ("Cat-first", 2026-07-13), which made the surface unreachable: + // the sidebar, the mobile tab bar, the breadcrumb "Dashboard" crumb, the 404 + // page and every RouteError recovery link all point here, so all of them + // silently landed on /dashboard/cat — the destination the "Cat" nav item + // already owned. Cat-first is still honoured where it belongs: sign-in goes + // to CAT_WELCOME (auth/callback + auth/confirm) and "/" redirects to the Cat. if (!hydrated || localLoading) { return ; @@ -108,12 +71,6 @@ export default function DashboardPage() { return null; } - // Cat-first: show the redirect loader for everyone except the timeline-error - // fallback (which renders the dashboard below so the user can retry). - if (!timelineError) { - return ; - } - return (
diff --git a/src/app/page.tsx b/src/app/page.tsx index a4f0264f2..2e0e228b2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -47,7 +47,11 @@ export default async function Home() { redirect(ROUTES.ONBOARDING.INTELLIGENT); } } - redirect(ROUTES.DASHBOARD.HOME); + // Cat-first: an already-onboarded visitor landing on "/" (or the brand + // mark, which points here) goes to the Cat hub. Deliberately NOT + // DASHBOARD.HOME — /dashboard is the dashboard again, and the sidebar's + // "Home" entry is what takes you there. + redirect(ROUTES.DASHBOARD.CAT); } return ; diff --git a/src/components/profile/ProfileBannerSection.tsx b/src/components/profile/ProfileBannerSection.tsx index c1f565a0f..cb7a8fbee 100644 --- a/src/components/profile/ProfileBannerSection.tsx +++ b/src/components/profile/ProfileBannerSection.tsx @@ -32,8 +32,14 @@ export function ProfileBannerSection({ onShareToggle, onFollowToggle, }: ProfileBannerSectionProps) { + // The gap under the banner is NOT set here. The avatar hangs past the + // banner's bottom edge, so whatever clears it has to know the overhang — and + // that is the identity card in ProfileLayout, which the avatar now overlaps. + // A margin here as well would be a second, blind opinion about the same seam, + // which is how the avatar ended up floating in a band of background that + // belonged to neither box. return ( -
+
{/* Banner — monochrome neutral default per migration 6/N; user banner_url image overlays on top. The dark-bottom overlay stays so action buttons remain readable. */} @@ -48,12 +54,16 @@ export function ProfileBannerSection({ className="object-cover" /> )} -
+
{/* Avatar */} -
+ {/* Overhang is half the avatar at every breakpoint (it was 1/2, 3/5, 2/3 + and 1/2 before), and the horizontal inset matches the identity card's + own padding (p-4 sm:p-6) so the avatar and the name share one left + edge instead of missing it by 8px. */} +
{profile.avatar_url ? (
)} -
+
diff --git a/src/components/profile/ProfileLayout.tsx b/src/components/profile/ProfileLayout.tsx index d11a26aa0..94913cfaa 100644 --- a/src/components/profile/ProfileLayout.tsx +++ b/src/components/profile/ProfileLayout.tsx @@ -228,8 +228,12 @@ export default function ProfileLayout({ onFollowToggle={handleFollowToggle} /> -
-
+
+ {/* Top padding clears the avatar's overhang (half its height) plus a + gap, so the avatar overlaps this card the way it overlaps the + banner — one continuous header rather than two boxes with a + portrait stranded in the white space between them. */} +

{profile.name || profile.username || 'User'}

diff --git a/src/components/profile/ProfileOfferings.tsx b/src/components/profile/ProfileOfferings.tsx index 78260e61f..c70e94b26 100644 --- a/src/components/profile/ProfileOfferings.tsx +++ b/src/components/profile/ProfileOfferings.tsx @@ -15,8 +15,9 @@ */ import Link from 'next/link'; -import { Sparkles, Plus, MinusCircle } from 'lucide-react'; +import { Sparkles, Plus, MinusCircle, Globe, MessageCircle } from 'lucide-react'; import { ENTITY_REGISTRY } from '@/config/entity-registry'; +import { ROUTES } from '@/config/routes'; import { suggestedEntityForSkill, type PublicEconomicProfile, @@ -27,21 +28,64 @@ interface ProfileOfferingsProps { isOwnProfile?: boolean; } +/** Opens the Cat with the question already typed. */ +function askCatHref(question: string): string { + return `${ROUTES.DASHBOARD.CAT}?q=${encodeURIComponent(question)}`; +} + +const DISCOVER_QUESTION = + "Help me work out what I can offer. Ask me what I'm good at, what I own that could earn, " + + 'and what other people come to me for — then put it on my profile.'; + +// The Cat can remove entries as well as add them (removeFromEconomicProfile), +// which is the only way to take something off this card. Say so, because a +// wrong entry here is wrong in public. +const CORRECT_QUESTION = + 'Go through "What I can offer" on my profile with me. Remove anything that is not right ' + + "and help me add what's missing."; + +function AskCatLink({ label, question }: { label: string; question: string }) { + return ( + + + {label} + + ); +} + export default function ProfileOfferings({ economicProfile, isOwnProfile }: ProfileOfferingsProps) { - if (!economicProfile) { - return null; - } - const skills = economicProfile.skills ?? []; - const askedFor = economicProfile.askedFor ?? []; - const assets = economicProfile.assets ?? []; - const notAvailableFor = economicProfile.notAvailableFor ?? []; - if ( + const skills = economicProfile?.skills ?? []; + const askedFor = economicProfile?.askedFor ?? []; + const assets = economicProfile?.assets ?? []; + const notAvailableFor = economicProfile?.notAvailableFor ?? []; + const isEmpty = skills.length === 0 && askedFor.length === 0 && assets.length === 0 && - notAvailableFor.length === 0 - ) { - return null; + notAvailableFor.length === 0; + + if (isEmpty) { + // A visitor sees nothing. The owner sees the way to fill it — an empty + // section is the one place the invitation is most useful, and it used to + // be the one place that rendered nothing at all. + if (!isOwnProfile) { + return null; + } + return ( +
+
+ +

What I can offer

+
+

+ Nothing here yet. This is the part of your profile a potential client actually reads. +

+ +
+ ); } return ( @@ -146,10 +190,20 @@ export default function ProfileOfferings({ economicProfile, isOwnProfile }: Prof
)} - {isOwnProfile && skills.length > 0 && ( -

- Tap a skill to turn it into a listing. Only you see these create shortcuts. -

+ {isOwnProfile && ( +
+ {/* "Only you see these create shortcuts" was true of the +buttons and + easy to read as though it covered the entries themselves. It does + not: everything above is on the public profile. Say which is which. */} +

+ + + Visible to everyone. + {skills.length > 0 && ' The + shortcuts are yours alone — tap one to list it.'} + +

+ +
)}
); diff --git a/src/components/profile/ProfileSkeleton.tsx b/src/components/profile/ProfileSkeleton.tsx index 2b412652b..dfe0a8ecb 100644 --- a/src/components/profile/ProfileSkeleton.tsx +++ b/src/components/profile/ProfileSkeleton.tsx @@ -89,21 +89,28 @@ export function ProjectsSkeleton() { export function ProfilePageSkeleton() { return (
-
+ {/* Every measurement below mirrors ProfileBannerSection / ProfileLayout. + It used to guess: a 192px banner where the real one is 128px on a + phone, a 96px avatar where the real one is 64px, a different inset and + a different overhang. So the header visibly jumped — banner shrank, + portrait slid — the moment real content replaced the skeleton, which + is half of why the area read as thrown together. A skeleton that does + not match is worse than no skeleton. */} +
{/* Header Banner Skeleton */} -
-
-
-
+
+
+
+
-
+
{/* Main Content Skeleton */} -
+
{/* Basic Info Card Skeleton */}
diff --git a/src/components/sidebar/ContextSwitcher.tsx b/src/components/sidebar/ContextSwitcher.tsx index 4167e5e3f..fbe20b97d 100644 --- a/src/components/sidebar/ContextSwitcher.tsx +++ b/src/components/sidebar/ContextSwitcher.tsx @@ -188,8 +188,24 @@ export function ContextSwitcher({ !isGroupContext && 'bg-surface-raised' )} > -
- + {/* Your own row showed a generic person glyph while every group + below it showed its real picture — the one row a person can + identify at a glance was the one rendered anonymously. */} +
+ {profile.avatar_url ? ( + {profile.name + ) : ( +
+ +
+ )}

diff --git a/src/components/ui/UserProfileDropdown.tsx b/src/components/ui/UserProfileDropdown.tsx index a5b839d30..f60493194 100644 --- a/src/components/ui/UserProfileDropdown.tsx +++ b/src/components/ui/UserProfileDropdown.tsx @@ -186,7 +186,7 @@ export default function UserProfileDropdown({ {isOpen && ( -

+
{menuItems.map(item => { const Icon = item.icon; diff --git a/src/hooks/useNavigationContext.ts b/src/hooks/useNavigationContext.ts index 190dc98f4..344dada1b 100644 --- a/src/hooks/useNavigationContext.ts +++ b/src/hooks/useNavigationContext.ts @@ -18,6 +18,7 @@ import { usePathname } from 'next/navigation'; import { useAuth } from './useAuth'; import { logger } from '@/utils/logger'; import { API_ROUTES } from '@/config/api-routes'; +import { isFixtureGroupTitle } from '@/config/public-directory'; type NavigationContextType = 'individual' | 'group'; @@ -116,14 +117,19 @@ export function useNavigationContext(): UseNavigationContextReturn { } const data = await response.json(); if (!cancelled && data.success && data.data?.groups) { - const groups: GroupContextInfo[] = data.data.groups.map( - (g: { id: string; slug: string; name: string; avatar_url?: string }) => ({ + const groups: GroupContextInfo[] = data.data.groups + // CI and workflow audits mint throwaway groups ("Audit WF009 …") + // and add the account they run as to them. Public directories + // already hide those rows; the switcher never got the same filter, + // so they sat in the user's own context menu as if they were real + // teams. Same predicate, one definition — see config/public-directory. + .filter((g: { name: string }) => !isFixtureGroupTitle(g.name)) + .map((g: { id: string; slug: string; name: string; avatar_url?: string }) => ({ id: g.id, slug: g.slug, name: g.name, avatar_url: g.avatar_url, - }) - ); + })); setUserGroups(groups); } } catch (error) { diff --git a/src/services/cat/economic-profile-prompt.ts b/src/services/cat/economic-profile-prompt.ts new file mode 100644 index 000000000..d79998dd9 --- /dev/null +++ b/src/services/cat/economic-profile-prompt.ts @@ -0,0 +1,40 @@ +/** + * The system prompt for passive economic extraction. + * + * It lives in its own module because it is prose, not logic: it changes for + * editorial reasons, on its own cadence, and reviewing a wording change should + * not mean scrolling past the merge/upsert code that happens to call it. + * + * `asked_for` carries the longest note for a reason. It used to read simply + * "what people come to them for", and the extractor runs over a chat with an + * assistant — where the most frequent ask by far is the user asking the Cat + * for something. So it recorded people's own requests and needs as things they + * SUPPLY, and the profile published them: a live account listed "vet care in + * Zurich" and "funding for dog surgery" under "People come to me for". + */ + +export const ECON_EXTRACTION_SYSTEM = `You extract a person's LATENT ECONOMIC VALUE from one chat exchange — only what they actually stated or clearly implied, never invented. + +Pull, where present: +- skills: things they can do (names). Treat self-deprecation ("it's nothing", "just a hobby", "anyone can do that") as a real skill worth capturing. +- assets: things they OWN that could be rented or sold. +- goals: what they want; each {text, kind} where kind is earn | fund | learn | connect | build. +- constraints: PRIVATE limits like "only evenings", "no upfront capital" — never shown publicly. +- asked_for: what OTHER PEOPLE come to THIS PERSON for — help they are sought out to give. + This exchange is a chat with an assistant, so the most frequent "ask" in it is the + person asking YOU for something. That is the opposite of this field. Never record what + they requested from you, and never record a need of their own ("funding for surgery", + "a vet in Zurich", "suggestions on what to offer") — those are things they WANT, and + this field is published on their public profile as something they SUPPLY. Capture it + only from a statement about other people coming to them, e.g. "friends always ask me + to fix their bikes". If in doubt, leave it empty. +- not_available_for: PUBLIC scope limits they'd want a prospective client/collaborator to see up front — e.g. "not taking full-time roles", "advisory only, no hands-on coding", "nothing under 3 months". Distinct from constraints: only capture this when they're describing what kind of engagement they will or won't take, not private life constraints. +- motivation: why they're here — earn | community | meaning | learn | unsure. +- stage: exploring | has-offers | scaling. + +Rules: ground everything in THIS exchange; omit anything not stated; never infer demand, prices, or stats. Output ONLY a JSON object with those keys (arrays empty if none), nothing else. Example: +{"skills":["translation"],"assets":[],"goals":[{"text":"earn on the side","kind":"earn"}],"constraints":[],"asked_for":["writing clear emails"],"not_available_for":[],"motivation":"earn","stage":null} + +That asked_for came from "colleagues keep asking me to clean up their emails" — a statement +about other people seeking them out. Had they instead asked you "can you help me write a +clearer email?", asked_for would be [].`; diff --git a/src/services/cat/economic-profile.ts b/src/services/cat/economic-profile.ts index 18348cc7d..a19d709a8 100644 --- a/src/services/cat/economic-profile.ts +++ b/src/services/cat/economic-profile.ts @@ -16,6 +16,7 @@ import type { AnySupabaseClient } from '@/lib/supabase/types'; import { DATABASE_TABLES } from '@/config/database-tables'; import { logger } from '@/utils/logger'; import { looksLikeSelfDisclosure, selectForgetFacts, type MemoryAiService } from './memory'; +import { ECON_EXTRACTION_SYSTEM } from './economic-profile-prompt'; export interface EconomicSkill { name: string; @@ -435,20 +436,6 @@ export function normalizeEconomicPatch( return hasAny ? patch : null; } -const ECON_EXTRACTION_SYSTEM = `You extract a person's LATENT ECONOMIC VALUE from one chat exchange — only what they actually stated or clearly implied, never invented. - -Pull, where present: -- skills: things they can do (names). Treat self-deprecation ("it's nothing", "just a hobby", "anyone can do that") as a real skill worth capturing. -- assets: things they OWN that could be rented or sold. -- goals: what they want; each {text, kind} where kind is earn | fund | learn | connect | build. -- constraints: PRIVATE limits like "only evenings", "no upfront capital" — never shown publicly. -- asked_for: what people come to them for. -- not_available_for: PUBLIC scope limits they'd want a prospective client/collaborator to see up front — e.g. "not taking full-time roles", "advisory only, no hands-on coding", "nothing under 3 months". Distinct from constraints: only capture this when they're describing what kind of engagement they will or won't take, not private life constraints. -- motivation: why they're here — earn | community | meaning | learn | unsure. -- stage: exploring | has-offers | scaling. - -Rules: ground everything in THIS exchange; omit anything not stated; never infer demand, prices, or stats. Output ONLY a JSON object with those keys (arrays empty if none), nothing else. Example: -{"skills":["translation"],"assets":[],"goals":[{"text":"earn on the side","kind":"earn"}],"constraints":[],"asked_for":["writing clear emails"],"not_available_for":[],"motivation":"earn","stage":null}`; /** * Passive, deterministic economic extraction — runs after each self-disclosing turn