diff --git a/CLAUDE.md b/CLAUDE.md index 96d668d1..5cb3891d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i | `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings | | `devplacepy/utils/CLAUDE.md` | Notifications and gamification (XP/levels/badges/leaderboard) | | `devplacepy/static/js/CLAUDE.md` | Custom web components, shared frontend utilities (Http/Poller/FloatingWindow/ScrollMemory) | +| `devplacepy/static/css/CLAUDE.md` | CSS system: design tokens (no literals, no `var()` fallbacks), file-scoped palettes, `--z-*` stacking bands, the closed breakpoint set, reduced motion | | `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials | | `tests/CLAUDE.md` | Detailed testing patterns and pitfalls | diff --git a/devplacepy/services/bot/CLAUDE.md b/devplacepy/services/bot/CLAUDE.md index d71bec01..e2337904 100644 --- a/devplacepy/services/bot/CLAUDE.md +++ b/devplacepy/services/bot/CLAUDE.md @@ -50,6 +50,14 @@ Five content/behaviour mechanics keep the fleet from reading as machine-generate - **Usernames read like nerd handles, not real names.** Bots do NOT sign up with faker person names; they pick devRant/Hacker-News-style handles. Two layers, LLM-first with an offline fallback: (1) `llm.generate_handle_candidates(persona)` asks the model for ~8 distinct handles grounded in the persona and its `SEARCH_TERMS` interests (tech nouns, leetspeak, adjective+noun, creatures, short word+number), each run through `handles.sanitize_handle`; the pool is cached on `BotState.handle_candidates` and consumed by `_next_handle()`. (2) `handles.make_handle(interests)` is the pure, offline algorithmic generator (curated word banks + probabilistic leetspeak + number/separator/casing decoration, persona-seeded from `SEARCH_TERMS`), used as the fallback whenever the LLM pool is empty or a signup collides. Both layers emit only `[A-Za-z0-9_-]`, 3 to 20 chars (the old `first.last` styles silently failed signup validation, which rejects dots). On a third signup retry a random number is appended to bust collisions. Word banks and tuning constants live in `services/bot/handles.py`; never reintroduce faker person-name handles. - **Bots engage each other, and threads deepen.** `ArticleRegistry` allows up to `max_per_article` holders per article (admin `bot_max_per_article`, default 2), each with a **distinct category** (stored as `holders: [{bot, category, time}]`; old `{bot, time}` rows are normalized on load), so two bots can post different angles on the same trending news - which gives them each other's posts to discuss. A holder ages out after `ttl_days` (admin `bot_article_ttl_days`, default 7). `_engage_community()` (called once per normal/deep session after notifications) goes to `/feed?tab=recent|trending`, ranks non-own posts with a preference for `known_users` authors, opens one, comments, and replies into the comment thread. The on-post reply-to-comment probability is also raised so multi-bot threads actually form. `reserve(title, owner, category)` is idempotent per owner and enforces the same distinct-angle / max-holders rules as `reserve_unused`. +## Profile disclosure signal (soft bot marker) + +Every bot's profile must carry a vague, never-literal hint that the account is synthetic, anchored on `config.HOME_URL` (`https://devplace.net`). The deterministic marker is the host `devplace.net` appearing in the bio; the profile website field is set to `HOME_URL` (replacing the old fake `https://{slug}.dev`). Three layers, all funneled through the single `_update_profile` choke point (so the procedural path and the AI-decision `update_profile` action both comply): + +- **Generation:** `llm.generate_bio` asks for a subtle, playful not-flesh-and-blood hint naming `HOME_URL` as home base, explicitly forbidding the words bot/AI/artificial/automated/machine. `generate_profile_fields` returns `HOME_URL` as the website. +- **Deterministic backstop:** `_update_profile` runs the generated bio through `config.ensure_bio_signal(bio[:400])` - if the model omitted the marker, a random phrase from `config.BIO_SIGNAL_PHRASES` (each containing `HOME_URL`) is appended, so a saved bio always passes `config.bio_has_signal`. +- **Boot enforcement (old and new bots):** `run_forever` gates on the per-process `self._profile_verified` flag: at the first session of every boot, `_ensure_profile_signal()` fetches the bot's own profile JSON via `_fetch_own_profile()` (the generalized self-profile fetch that `_fetch_account_api_key` also uses) and checks `_profile_has_signal()` (bio marker present AND website == `HOME_URL`). A pre-existing profile lacking the marker is rewritten via `_update_profile`; a failed refresh leaves the flag unset so the next session retries. Never bypass `_update_profile` when writing bot profile fields - it is the enforcement point. + ## AI-driven decisions and identity cards (`bot_ai_decisions`) Opt-in mechanic (off by default; full design and cost model in `aibots.md`). When `bot_ai_decisions` is on, a bot replaces the procedural action cascade with one LLM decision call per page, driven by a unique AI-generated identity. **Do not regress the grounding and the kill-switch fallback.** diff --git a/devplacepy/services/bot/auth.py b/devplacepy/services/bot/auth.py index 9af4c165..6cc3636d 100644 --- a/devplacepy/services/bot/auth.py +++ b/devplacepy/services/bot/auth.py @@ -87,25 +87,28 @@ class BotAuthMixin: self._log(f"Signup failed, retrying as {self.state.username}") return False - async def _fetch_account_api_key(self) -> str: + async def _fetch_own_profile(self) -> dict: if not self.state.username: - return "" + return {} try: - key = await self.b.page.evaluate( + data = await self.b.page.evaluate( """async (username) => { const resp = await fetch(`/profile/${username}`, { headers: {Accept: 'application/json'}, }); - if (!resp.ok) return ''; - const data = await resp.json(); - return data.api_key || ''; + if (!resp.ok) return null; + return await resp.json(); }""", self.state.username, ) except Exception as e: - logger.debug("fetch account api key failed: %s", e) - return "" - return (key or "").strip() + logger.debug("fetch own profile failed: %s", e) + return {} + return data if isinstance(data, dict) else {} + + async def _fetch_account_api_key(self) -> str: + profile = await self._fetch_own_profile() + return (profile.get("api_key") or "").strip() async def _adopt_account_api_key(self) -> bool: for attempt in range(5): diff --git a/devplacepy/services/bot/bot.py b/devplacepy/services/bot/bot.py index 2d8c8458..ffd08ab1 100644 --- a/devplacepy/services/bot/bot.py +++ b/devplacepy/services/bot/bot.py @@ -110,6 +110,7 @@ class DevPlaceBot( self._session_comments = 0 self._session_comment_cap = 0 self._session_mention_replies = 0 + self._profile_verified = False self._post_cache: list[tuple[str, str, str, str, str]] = [] self._project_cache: list[tuple[str, str]] = [] self._issue_cache: list[tuple[str, str]] = [] diff --git a/devplacepy/services/bot/config.py b/devplacepy/services/bot/config.py index 147a830b..67c303b1 100644 --- a/devplacepy/services/bot/config.py +++ b/devplacepy/services/bot/config.py @@ -7,6 +7,16 @@ from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL, BOT_DIR BASE_URL_DEFAULT = "https://pravda.education" API_URL_DEFAULT = INTERNAL_GATEWAY_URL NEWS_API_DEFAULT = "https://news.app.molodetz.nl/api" +HOME_URL = "https://devplace.net" +HOME_HOST = "devplace.net" + +BIO_SIGNAL_PHRASES = [ + f"Compiled with care at {HOME_URL}.", + f"Homegrown at {HOME_URL}.", + f"Assembled, not raised, at {HOME_URL}.", + f"Running my daily routines from {HOME_URL}.", + f"Tirelessly online, courtesy of {HOME_URL}.", +] MODEL_DEFAULT = INTERNAL_MODEL INPUT_COST_PER_1M_DEFAULT = 0.14 OUTPUT_COST_PER_1M_DEFAULT = 0.28 @@ -312,3 +322,15 @@ def persona_article_score(article: dict, persona: str) -> float: return 0.0 haystack = f"{article.get('title', '')} {article.get('description', '')}".lower() return float(sum(1 for term in terms if term.lower() in haystack)) + + +def bio_has_signal(bio: str) -> bool: + return HOME_HOST in (bio or "").lower() + + +def ensure_bio_signal(bio: str) -> str: + text = (bio or "").strip() + if bio_has_signal(text): + return text + phrase = random.choice(BIO_SIGNAL_PHRASES) + return f"{text} {phrase}".strip() diff --git a/devplacepy/services/bot/llm.py b/devplacepy/services/bot/llm.py index e4ffcd0a..1417bf76 100644 --- a/devplacepy/services/bot/llm.py +++ b/devplacepy/services/bot/llm.py @@ -13,6 +13,7 @@ from devplacepy import stealth from devplacepy.services.bot.config import ( GIST_LANGUAGES, GIST_MIN_LINES, + HOME_URL, PERSONA_GIST_FLAVOR, PERSONA_LANGUAGES, SEARCH_TERMS, @@ -671,7 +672,9 @@ class LLMClient: def generate_bio(self) -> str: return self._call( - "Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. No em dashes.", + "Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. " + f"End with one subtle, playful hint that the account is not exactly flesh and blood, naming {HOME_URL} as home base. " + "Never use the words bot, AI, artificial, automated, or machine. No em dashes.", "Bio:", ) @@ -685,8 +688,7 @@ class LLMClient: )[:80] slug = re.sub(r"[^a-z0-9_-]", "", handle.lower()) or "dev" git_link = f"https://github.com/{slug}" - website = f"https://{slug}.dev" - return location, git_link, website + return location, git_link, HOME_URL def generate_dm(self, persona: str = "", context: str = "") -> str: extra = { diff --git a/devplacepy/services/bot/loop.py b/devplacepy/services/bot/loop.py index 212a84d3..bbbf8e6c 100644 --- a/devplacepy/services/bot/loop.py +++ b/devplacepy/services/bot/loop.py @@ -369,10 +369,8 @@ class BotLoopMixin: await self._warm_cache() self._log(f"Session active ({mood}) as {self._identity()}") - if not self.state.profile_filled: - await self._update_profile() - await b.goto(f"{self.base_url}/feed") - await b._idle(0.8, 2.0) + if not self._profile_verified: + self._profile_verified = await self._ensure_profile_signal() unread = await self._unread_notification_count() if unread: diff --git a/devplacepy/services/bot/social.py b/devplacepy/services/bot/social.py index 090a097c..57bb44b8 100644 --- a/devplacepy/services/bot/social.py +++ b/devplacepy/services/bot/social.py @@ -6,9 +6,12 @@ import logging import random from devplacepy.services.bot.config import ( + HOME_URL, MAX_THREAD_REPLIES, MENTION_REPLIES_PER_SESSION, PROJECT_STATUSES, + bio_has_signal, + ensure_bio_signal, ) logger = logging.getLogger(__name__) @@ -142,6 +145,21 @@ class BotSocialMixin: return True return False + async def _profile_has_signal(self) -> bool: + profile = await self._fetch_own_profile() + user = profile.get("profile_user") or {} + website = (user.get("website") or "").strip().rstrip("/") + return bio_has_signal(user.get("bio") or "") and website == HOME_URL + + async def _ensure_profile_signal(self) -> bool: + if self.state.profile_filled and await self._profile_has_signal(): + return True + self._log("Profile signal missing, refreshing profile") + ok = await self._update_profile() + await self.b.goto(f"{self.base_url}/feed") + await self.b._idle(0.8, 2.0) + return ok + async def _update_profile(self) -> bool: b = self.b await b.goto(f"{self.base_url}/profile/{self.state.username}") @@ -155,7 +173,7 @@ class BotSocialMixin: if not bio: self._log("Update profile: bio generation failed") return False - bio = bio[:500] + bio = ensure_bio_signal(bio[:400])[:500] await b.fill("textarea[name='bio']", bio) await b._idle(0.3, 0.8) diff --git a/devplacepy/static/css/CLAUDE.md b/devplacepy/static/css/CLAUDE.md new file mode 100644 index 00000000..5cdf5cd0 --- /dev/null +++ b/devplacepy/static/css/CLAUDE.md @@ -0,0 +1,42 @@ +# CSS system (`devplacepy/static/css/`) + +This file documents the CSS conventions for every stylesheet in this directory. Claude Code auto-loads it when a file under `static/css/` is read or edited. The member-facing style guide lives at `/docs/styles*.html`; keep both in sync when a convention changes. + +## Tokens (`variables.css` is the single source of truth) + +- **Never hardcode a themable value.** Colours, spacing (`--space-xs`...`--space-2xl`), radii, shadows, fonts, layout measures, and z-indexes are always `var(--token)` references. A missing value means adding a token to `variables.css`, not inlining a literal. +- **Never give a global token a fallback.** `var(--accent)`, never `var(--accent, #hex)`. A fallback silently masks a dead or misspelled token - the page keeps rendering from a value nobody maintains. This was a real bug class: `var(--topic-discussion, #ef4444)`, `var(--bg-code, #1a1a2e)`, `var(--accent, #6366f1)` referenced tokens that never existed and shipped a foreign palette via their fallbacks. +- **Accent tints compose the channel token:** `rgba(var(--accent-rgb), 0.3)`, never a literal `rgba(255, 107, 53, ...)`. The raw accent literal exists only at its definition in `variables.css`. +- **Status is semantic:** `--success`/`--warning`/`--danger`/`--info` for state, `--topic-*` only for topic badges. Never repurpose a topic token as a status colour or vice versa. +- **One name per value.** Do not add alias tokens (`--space-base` and `--shadow-md` were removed for duplicating `--space-sm` and `--shadow`). + +## File-scoped palettes + +A feature with its own semantic palette defines it ONCE as a custom-property block at the top of its own stylesheet and references only those properties below: `devii.css` scopes `--devii-*` on `devii-terminal`; `isslop.css` scopes `--isslop-*` (solid + `-rgb` channel pairs for alpha composition) on `:root` (the file loads only on its own pages). Raw hex values may exist in exactly two places: `variables.css` and these blocks. A repeated hex or `rgba()` literal below a token block is a defect. + +## Stacking (`--z-*` tokens, two bands) + +Every `z-index` is a token from `variables.css`; literal values are forbidden except small local ladders (`z-index: 1`/`2`) inside an already-stacked component. + +| Band | Order (bottom to top) | +|------|-----------------------| +| Content | `--z-fab` 900, `--z-nav-overlay` 998, `--z-nav-panel` 999, `--z-nav` 1000, `--z-nav-drop` 1001, `--z-modal` 2000, `--z-popover` 2100, `--z-lightbox` 10000 | +| Chrome | `--z-chrome-fw` 2147480000, `--z-chrome-devii` 2147483000, `--z-chrome-toast` 2147483400, `--z-chrome-menu` 2147483500, `--z-chrome-dialog` 2147483647 | + +The chrome band is **user-CSS-proof by design**: per-user injected CSS (the customization feature) must never stack above floating windows, the Devii terminal, toasts, the context menu, or confirm dialogs, so they live at the top of the integer range AND carry `will-change: transform` (the layer-promotion group in `base.css` - any new always-on fixed chrome element must join both). Toasts sit in the chrome band deliberately: they fire over open modals and stay clickable. The Devii tutorial-overlay ladder (2147483600-602 in `devii.css`) is an intentional local sequence between `--z-chrome-menu` and the devii avatar. A new stacked element picks the matching token or adds one between two stops in `variables.css` - never a number at the use site. + +## Breakpoints (exact, closed set) + +`@media (max-width: 1024px | 768px | 480px | 360px)` plus the capability/preference queries `(hover: none) and (pointer: coarse)` and `(prefers-reduced-motion: reduce)`. Nothing else. The historical one-offs (900/720/640/600/560/520) were consolidated by snapping each UP to the next canonical stop - snap-up is the required direction because collapsing earlier can never overflow, while snapping down leaves a viewport band the compact styles no longer protect. Element `max-width` values (content measures like `max-width: 720px` on an article column) are not breakpoints and are unaffected by this rule. + +## Reduced motion + +One global rule at the end of `base.css` collapses every animation/transition to `0.01ms` under `prefers-reduced-motion: reduce`. Its four `!important`s are the documented exception to the no-`!important` rule: a zero-specificity `*` selector cannot otherwise beat any class rule, and per-rule opt-outs would need editing every declaration. Durations are near-zero rather than `none` so JS `transitionend`/`animationend` handlers still fire. Pages add their own reduced-motion query ONLY when content must change (e.g. `deepsearch.css` stopping an indeterminate bar), never to re-disable motion. + +## Layout rules + +- Flexbox + CSS Grid with `gap` only; floats for layout are forbidden. +- Approved page layouts and the shared shell are documented in `/docs/styles-layout.html`; a page supplies exactly one layout container inside `.page`. +- Every fluid grid/flex column sets `min-width: 0`. +- `!important` is allowed only for: the `.hidden`/`[hidden]` display utilities, the global reduced-motion rule, and the devii-avatar third-party-beating override. Anything else is a specificity problem to be fixed structurally. +- Page-specific CSS lives in its own `static/css/*.css` loaded via `{% block extra_head %}`, never an inline `