forked from retoor/devplacepy
feat: add /stop and /reset chat commands and bots internals docs section
Add two new chat commands (`/stop` and `/reset`) to the Devii WebSocket handler, enabling users to stop or reset a session via text input. Introduce a new "Bots internals" documentation section with six prose pages covering architecture, personas, content generation, engagement, realism, and configuration for the autonomous bot fleet. Extend the bot service with article scoring, category picking, configurable pause/break timing, and a `gist_min_lines` parameter for LLM client initialization.
This commit is contained in:
+161
-31
@@ -19,6 +19,8 @@ from devplacepy.services.bot.config import (
|
||||
REACT_RATE_DEFAULT,
|
||||
REACT_RATES,
|
||||
SEARCH_TERMS,
|
||||
persona_article_score,
|
||||
pick_category,
|
||||
)
|
||||
from devplacepy.services.bot.llm import LLMClient
|
||||
from devplacepy.services.bot.news_fetcher import NewsFetcher
|
||||
@@ -44,16 +46,22 @@ class DevPlaceBot:
|
||||
cfg.model,
|
||||
cfg.input_cost_per_1m,
|
||||
cfg.output_cost_per_1m,
|
||||
gist_min_lines=cfg.gist_min_lines,
|
||||
)
|
||||
self.llm.total_cost = self.state.total_cost
|
||||
self.llm.total_calls = self.state.total_calls
|
||||
self.llm.total_in_tokens = self.state.total_in_tokens
|
||||
self.llm.total_out_tokens = self.state.total_out_tokens
|
||||
self.news = NewsFetcher(cfg.news_api)
|
||||
self.registry = ArticleRegistry(cfg.registry_path)
|
||||
self.registry = ArticleRegistry(
|
||||
cfg.registry_path, cfg.max_per_article, cfg.article_ttl_days
|
||||
)
|
||||
self._pause_min = min(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._pause_max = max(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._break_scale = max(0.1, cfg.break_scale)
|
||||
self.faker = Faker()
|
||||
self.b = BotBrowser(headless=cfg.headless)
|
||||
self._post_cache: list[tuple[str, str, str, str]] = []
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
self._project_cache: list[tuple[str, str]] = []
|
||||
self._bug_cache: list[tuple[str, str]] = []
|
||||
self._session_start_cost: float = 0.0
|
||||
@@ -131,15 +139,31 @@ class DevPlaceBot:
|
||||
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
|
||||
)
|
||||
|
||||
def _pick_article(self, articles: list[dict]) -> Optional[dict]:
|
||||
if not articles:
|
||||
return None
|
||||
ranked = sorted(
|
||||
articles,
|
||||
key=lambda a: persona_article_score(a, self.state.persona)
|
||||
+ random.random(),
|
||||
reverse=True,
|
||||
)
|
||||
top = ranked[: max(3, len(ranked) // 3)]
|
||||
return random.choice(top)
|
||||
|
||||
async def _refill_cache_async(self, kind: str) -> None:
|
||||
try:
|
||||
if kind == "post":
|
||||
articles = self.news.fetch()
|
||||
if articles:
|
||||
a = random.choice(articles)
|
||||
a = self._pick_article(articles)
|
||||
if a:
|
||||
desc = a.get("description", "")[:500]
|
||||
cat = await asyncio.to_thread(
|
||||
self.llm.select_category, a["title"], desc[:600]
|
||||
cat = pick_category(self.state.persona)
|
||||
post_title = await asyncio.to_thread(
|
||||
self.llm.generate_post_title,
|
||||
a["title"],
|
||||
self.state.persona,
|
||||
cat,
|
||||
)
|
||||
content = await asyncio.to_thread(
|
||||
self.llm.generate_post,
|
||||
@@ -148,10 +172,16 @@ class DevPlaceBot:
|
||||
self.state.persona,
|
||||
cat,
|
||||
)
|
||||
self._post_cache.append((a["title"], desc, content, cat))
|
||||
self._post_cache.append(
|
||||
(a["title"], post_title or "", desc, content, cat)
|
||||
)
|
||||
elif kind == "project":
|
||||
t = (await asyncio.to_thread(self.llm.generate_project_title))[:200]
|
||||
desc = await asyncio.to_thread(self.llm.generate_project_desc, t)
|
||||
t = await asyncio.to_thread(
|
||||
self.llm.generate_project_title, self.state.persona
|
||||
)
|
||||
desc = await asyncio.to_thread(
|
||||
self.llm.generate_project_desc, t, self.state.persona
|
||||
)
|
||||
self._project_cache.append((t, desc))
|
||||
elif kind == "bug":
|
||||
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
|
||||
@@ -951,14 +981,27 @@ class DevPlaceBot:
|
||||
self._log("Create gist: modal not found")
|
||||
return False
|
||||
|
||||
generated = await self._generate(self.llm.generate_gist, self.state.persona)
|
||||
generated = None
|
||||
for attempt in range(2):
|
||||
candidate = await self._generate(self.llm.generate_gist, self.state.persona)
|
||||
if not candidate:
|
||||
continue
|
||||
cand_title, cand_desc, cand_lang, cand_code = candidate
|
||||
if not cand_code.strip():
|
||||
continue
|
||||
verdict = await self._generate(
|
||||
self.llm.gist_quality_check, cand_title, cand_code, cand_lang
|
||||
)
|
||||
if verdict is None or verdict[0]:
|
||||
generated = candidate
|
||||
break
|
||||
self.state.quality_rejections += 1
|
||||
action = "regenerating" if attempt == 0 else "skipping"
|
||||
self._log(f"Gist quality reject ({verdict[1]}), {action}")
|
||||
if not generated:
|
||||
self._log("Create gist: generation failed")
|
||||
self._log("Create gist: no quality candidate")
|
||||
return False
|
||||
title, desc, language, code = generated
|
||||
if not code.strip():
|
||||
self._log("Create gist: empty code generated")
|
||||
return False
|
||||
|
||||
title = self.llm.strip_md(title)[:200] or title[:200]
|
||||
if not await b.fill("#gist-title", title):
|
||||
@@ -1038,33 +1081,43 @@ class DevPlaceBot:
|
||||
|
||||
article_title = ""
|
||||
article_desc = ""
|
||||
post_title = ""
|
||||
|
||||
if self._post_cache:
|
||||
article_title, article_desc, content, topic = self._post_cache.pop(0)
|
||||
article_title, post_title, article_desc, content, topic = (
|
||||
self._post_cache.pop(0)
|
||||
)
|
||||
asyncio.create_task(self._refill_cache_async("post"))
|
||||
else:
|
||||
articles = self.news.fetch()
|
||||
topic = pick_category(self.state.persona)
|
||||
article = self.registry.reserve_unused(
|
||||
articles, self.state.username, self.llm.clean
|
||||
articles,
|
||||
self.state.username,
|
||||
self.llm.clean,
|
||||
topic,
|
||||
self.state.persona,
|
||||
)
|
||||
if not article:
|
||||
self._log("Create post: no unused articles available")
|
||||
return False
|
||||
desc = article.get("description", "")[:500]
|
||||
topic = await self._generate(
|
||||
self.llm.select_category, article["title"], desc[:600]
|
||||
post_title = await self._generate(
|
||||
self.llm.generate_post_title,
|
||||
article["title"],
|
||||
self.state.persona,
|
||||
topic,
|
||||
)
|
||||
content = await self._generate(
|
||||
self.llm.generate_post,
|
||||
article["title"],
|
||||
desc,
|
||||
self.state.persona,
|
||||
topic or "random",
|
||||
topic,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: generation failed/empty")
|
||||
return False
|
||||
topic = topic or "random"
|
||||
article_title = article["title"]
|
||||
article_desc = desc
|
||||
|
||||
@@ -1099,12 +1152,16 @@ class DevPlaceBot:
|
||||
|
||||
clean_title = self.llm.clean(article_title[:200]) if article_title else ""
|
||||
if clean_title:
|
||||
if not self.registry.reserve(clean_title, self.state.username):
|
||||
if not self.registry.reserve(clean_title, self.state.username, topic):
|
||||
self._log(
|
||||
f"Create post: article '{clean_title[:50]}...' already posted by another bot, skipping"
|
||||
f"Create post: article '{clean_title[:50]}...' already covered, skipping"
|
||||
)
|
||||
return False
|
||||
|
||||
type_title = self.llm.strip_md(post_title)[:120] if post_title else ""
|
||||
if not type_title:
|
||||
type_title = clean_title
|
||||
|
||||
ts = f"input[name='topic'][value='{topic}']"
|
||||
if await b.page.locator(ts).count() == 0:
|
||||
self._log(f"Create post: topic radio '{topic}' not found")
|
||||
@@ -1112,8 +1169,8 @@ class DevPlaceBot:
|
||||
await b.click(ts)
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
if clean_title:
|
||||
ok = await b.fill("input[name='title']", clean_title)
|
||||
if type_title:
|
||||
ok = await b.fill("input[name='title']", type_title)
|
||||
if not ok:
|
||||
self._log("Create post: title input not found")
|
||||
await b._idle(0.2, 0.4)
|
||||
@@ -1150,7 +1207,7 @@ class DevPlaceBot:
|
||||
self.state.known_posts.append(post_url)
|
||||
self._action(
|
||||
"POST",
|
||||
f"{topic} [{self.state.created_posts}]: {clean_title[:50] or content[:50]} -> {post_url[-45:]}",
|
||||
f"{topic} [{self.state.created_posts}]: {type_title[:50] or content[:50]} -> {post_url[-45:]}",
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -1273,18 +1330,22 @@ class DevPlaceBot:
|
||||
raw_title, desc = self._project_cache.pop(0)
|
||||
asyncio.create_task(self._refill_cache_async("project"))
|
||||
else:
|
||||
raw_title = await self._generate(self.llm.generate_project_title)
|
||||
raw_title = await self._generate(
|
||||
self.llm.generate_project_title, self.state.persona
|
||||
)
|
||||
if not raw_title:
|
||||
self._log("Create project: title generation failed")
|
||||
return False
|
||||
raw_title = raw_title[:200]
|
||||
desc = await self._generate(self.llm.generate_project_desc, raw_title)
|
||||
desc = await self._generate(
|
||||
self.llm.generate_project_desc, raw_title, self.state.persona
|
||||
)
|
||||
if not desc:
|
||||
self._log("Create project: description generation failed")
|
||||
return False
|
||||
desc = desc[:5000]
|
||||
|
||||
title = self.llm.strip_md(raw_title)[:200] or raw_title[:200]
|
||||
title = self.llm.strip_md(raw_title)[:120] or raw_title[:120]
|
||||
types = ["game", "software", "mobile_app", "website", "game_asset"]
|
||||
ptype = random.choice(types)
|
||||
status = random.choice(PROJECT_STATUSES)
|
||||
@@ -1817,6 +1878,73 @@ class DevPlaceBot:
|
||||
await self.b._idle(0.8, 1.8)
|
||||
return True
|
||||
|
||||
async def _engage_community(self) -> bool:
|
||||
b = self.b
|
||||
tab = random.choice(["recent", "recent", "trending"])
|
||||
await b.goto(f"{self.base_url}/feed?tab={tab}")
|
||||
await b._idle(0.8, 1.8)
|
||||
links = b.page.locator("a[href*='/posts/']")
|
||||
count = await links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
own = set(self.state.own_post_urls)
|
||||
known = {u.lower() for u in self.state.known_users if u}
|
||||
me = self.state.username.lower()
|
||||
ranked: list[tuple[float, int, str]] = []
|
||||
for i in range(min(count, 30)):
|
||||
link = links.nth(i)
|
||||
href = await link.get_attribute("href") or ""
|
||||
if "/posts/" not in href or len(href) < 20:
|
||||
continue
|
||||
if any(href in u for u in own):
|
||||
continue
|
||||
score = random.random()
|
||||
try:
|
||||
card = link.locator(
|
||||
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
|
||||
).first
|
||||
if await card.count() > 0:
|
||||
author = card.locator("a[href*='/profile/']").first
|
||||
if await author.count() > 0:
|
||||
ah = await author.get_attribute("href") or ""
|
||||
uname = (
|
||||
ah.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
|
||||
)
|
||||
if uname and uname == me:
|
||||
continue
|
||||
if uname in known:
|
||||
score += 2.0
|
||||
except Exception:
|
||||
pass
|
||||
ranked.append((score, i, href))
|
||||
if not ranked:
|
||||
return False
|
||||
ranked.sort(key=lambda r: r[0], reverse=True)
|
||||
idx, href = ranked[0][1], ranked[0][2]
|
||||
try:
|
||||
await links.nth(idx).click(timeout=5000)
|
||||
except Exception as e:
|
||||
logger.debug("_engage_community open failed: %s", e)
|
||||
return False
|
||||
await b._idle(1.0, 2.0)
|
||||
if href and href not in self.state.known_posts:
|
||||
self.state.known_posts.append(href)
|
||||
await self._read_like_human(3, 60)
|
||||
acted = False
|
||||
if await self._comment_on_post():
|
||||
acted = True
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.6 and await self._reply_to_random_comment():
|
||||
acted = True
|
||||
await b._idle(0.4, 1.2)
|
||||
if random.random() < 0.7:
|
||||
await self._vote_on_feed()
|
||||
if random.random() < 0.5:
|
||||
await self._react_to_object()
|
||||
if acted:
|
||||
self._action("ENGAGE", f"community thread {href[-40:]}")
|
||||
return acted
|
||||
|
||||
async def _cycle(
|
||||
self,
|
||||
mood: str = "normal",
|
||||
@@ -1884,7 +2012,7 @@ class DevPlaceBot:
|
||||
if await self._comment_on_post():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.35:
|
||||
if random.random() < 0.5:
|
||||
if await self._reply_to_random_comment():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
@@ -2143,6 +2271,8 @@ class DevPlaceBot:
|
||||
await b._idle(0.5, 1.5)
|
||||
await self._check_notifications()
|
||||
await b._idle(1.0, 3.0)
|
||||
await self._engage_community()
|
||||
await b._idle(0.8, 2.0)
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
|
||||
session_actions = 0
|
||||
@@ -2201,7 +2331,7 @@ class DevPlaceBot:
|
||||
self._log(f"Fatigue ({fatigue}): ending session")
|
||||
break
|
||||
|
||||
intra_pause = random.randint(5, 30)
|
||||
intra_pause = random.randint(self._pause_min, self._pause_max)
|
||||
self._log(
|
||||
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
|
||||
)
|
||||
@@ -2237,7 +2367,7 @@ class DevPlaceBot:
|
||||
break_s = random.randint(30, 120)
|
||||
self._log(f"Recovery wait {break_s}s")
|
||||
else:
|
||||
break_s = random.randint(*break_range)
|
||||
break_s = max(5, int(random.randint(*break_range) * self._break_scale))
|
||||
break_h = break_s / 3600
|
||||
self._log(f"Session done. Break {break_h:.1f}h")
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
@@ -17,6 +18,11 @@ ARTICLE_REGISTRY_PATH = Path.home() / ".dpbot_article_registry.json"
|
||||
|
||||
PROJECT_STATUSES = ["In Development", "Released"]
|
||||
|
||||
ARTICLE_TTL_DAYS = 7
|
||||
ACTION_PAUSE_MIN_SECONDS = 5
|
||||
ACTION_PAUSE_MAX_SECONDS = 30
|
||||
BREAK_SCALE_DEFAULT = 1.0
|
||||
|
||||
PERSONAS = [
|
||||
"enthusiastic_junior",
|
||||
"grumpy_senior",
|
||||
@@ -103,3 +109,142 @@ GENERIC_COMMENT_PHRASES = [
|
||||
"this is great",
|
||||
"good read",
|
||||
]
|
||||
|
||||
GIST_MIN_LINES = 6
|
||||
|
||||
TRIVIAL_GIST_TERMS = [
|
||||
"ternary",
|
||||
"hello world",
|
||||
"hello, world",
|
||||
"fizzbuzz",
|
||||
"fizz buzz",
|
||||
"delete pointer",
|
||||
"free pointer",
|
||||
"getter",
|
||||
"setter",
|
||||
"increment",
|
||||
"decrement",
|
||||
"swap two",
|
||||
"swap variables",
|
||||
"is even",
|
||||
"is odd",
|
||||
"reverse a string",
|
||||
"reverse string",
|
||||
"print hello",
|
||||
"for loop",
|
||||
"while loop",
|
||||
"basic loop",
|
||||
"add two numbers",
|
||||
"sum two",
|
||||
"concatenate strings",
|
||||
"string concat",
|
||||
"variable declaration",
|
||||
"comment example",
|
||||
]
|
||||
|
||||
PERSONA_LANGUAGES = {
|
||||
"enthusiastic_junior": ["python", "javascript", "typescript", "html", "css"],
|
||||
"grumpy_senior": ["c", "cpp", "rust", "sql", "bash"],
|
||||
"hobbyist_maker": ["python", "cpp", "lua", "javascript", "bash"],
|
||||
"academic_type": ["rust", "python", "go", "sql", "cpp"],
|
||||
"minimalist": ["go", "c", "bash", "lua"],
|
||||
"storyteller": ["python", "ruby", "javascript", "sql"],
|
||||
"rebel": ["rust", "go", "typescript", "bash"],
|
||||
"mentor": ["python", "java", "typescript", "sql"],
|
||||
}
|
||||
|
||||
PERSONA_GIST_FLAVOR = {
|
||||
"enthusiastic_junior": "a handy helper you recently learned and were glad to discover",
|
||||
"grumpy_senior": "a battle-tested utility that sidesteps a common footgun",
|
||||
"hobbyist_maker": "a fun, hands-on snippet for a side project or hardware tinkering",
|
||||
"academic_type": "an elegant algorithm or data-structure trick",
|
||||
"minimalist": "a tiny, sharp, dependency-free utility",
|
||||
"storyteller": "a snippet born from a real debugging or production story",
|
||||
"rebel": "an unconventional approach that beats the obvious one",
|
||||
"mentor": "a clear, reusable helper worth teaching to a junior",
|
||||
}
|
||||
|
||||
PERSONA_CATEGORY_WEIGHTS = {
|
||||
"enthusiastic_junior": {
|
||||
"showcase": 3,
|
||||
"question": 3,
|
||||
"devlog": 2,
|
||||
"fun": 2,
|
||||
"random": 1,
|
||||
"rant": 1,
|
||||
},
|
||||
"grumpy_senior": {
|
||||
"rant": 4,
|
||||
"devlog": 2,
|
||||
"random": 2,
|
||||
"question": 1,
|
||||
"showcase": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
"hobbyist_maker": {
|
||||
"showcase": 3,
|
||||
"devlog": 3,
|
||||
"fun": 2,
|
||||
"random": 2,
|
||||
"question": 1,
|
||||
"rant": 1,
|
||||
},
|
||||
"academic_type": {
|
||||
"devlog": 3,
|
||||
"question": 3,
|
||||
"random": 2,
|
||||
"showcase": 1,
|
||||
"rant": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
"minimalist": {
|
||||
"random": 3,
|
||||
"rant": 2,
|
||||
"devlog": 2,
|
||||
"showcase": 1,
|
||||
"question": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
"storyteller": {
|
||||
"devlog": 4,
|
||||
"showcase": 2,
|
||||
"fun": 2,
|
||||
"random": 2,
|
||||
"question": 1,
|
||||
"rant": 1,
|
||||
},
|
||||
"rebel": {
|
||||
"rant": 4,
|
||||
"fun": 2,
|
||||
"question": 2,
|
||||
"random": 1,
|
||||
"showcase": 1,
|
||||
"devlog": 1,
|
||||
},
|
||||
"mentor": {
|
||||
"devlog": 3,
|
||||
"question": 3,
|
||||
"showcase": 2,
|
||||
"random": 1,
|
||||
"rant": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
}
|
||||
|
||||
MAX_BOTS_PER_ARTICLE = 2
|
||||
|
||||
|
||||
def pick_category(persona: str) -> str:
|
||||
weights = PERSONA_CATEGORY_WEIGHTS.get(persona)
|
||||
if not weights:
|
||||
return random.choice(CATEGORIES)
|
||||
population = list(weights.keys())
|
||||
return random.choices(population, weights=list(weights.values()), k=1)[0]
|
||||
|
||||
|
||||
def persona_article_score(article: dict, persona: str) -> float:
|
||||
terms = SEARCH_TERMS.get(persona)
|
||||
if not terms:
|
||||
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))
|
||||
|
||||
+137
-37
@@ -6,7 +6,13 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from devplacepy.services.bot.config import CATEGORIES, GIST_LANGUAGES
|
||||
from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
GIST_MIN_LINES,
|
||||
PERSONA_GIST_FLAVOR,
|
||||
PERSONA_LANGUAGES,
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,6 +25,7 @@ class LLMClient:
|
||||
model: str,
|
||||
input_cost_per_1m: float,
|
||||
output_cost_per_1m: float,
|
||||
gist_min_lines: int = GIST_MIN_LINES,
|
||||
):
|
||||
if not api_key:
|
||||
raise RuntimeError("LLM API key not set")
|
||||
@@ -27,6 +34,7 @@ class LLMClient:
|
||||
self.model = model
|
||||
self.input_cost_per_1m = input_cost_per_1m
|
||||
self.output_cost_per_1m = output_cost_per_1m
|
||||
self.gist_min_lines = max(1, gist_min_lines)
|
||||
self.total_cost = 0.0
|
||||
self.total_calls = 0
|
||||
self.total_in_tokens = 0
|
||||
@@ -89,7 +97,7 @@ class LLMClient:
|
||||
def clean(text: str, preserve_md: bool = False) -> str:
|
||||
if not preserve_md:
|
||||
text = re.sub(r"\*\*|__|\*|_", "", text)
|
||||
text = text.replace("-", "-").replace("–", "-")
|
||||
text = text.replace("—", "-").replace("–", "-")
|
||||
text = text.replace("‘", "'").replace("’", "'")
|
||||
text = text.replace("“", '"').replace("”", '"')
|
||||
text = re.sub(r"\s{2,}", " ", text)
|
||||
@@ -99,6 +107,36 @@ class LLMClient:
|
||||
def strip_md(text: str) -> str:
|
||||
return LLMClient.clean(text)[:200]
|
||||
|
||||
@staticmethod
|
||||
def strip_label(text: str) -> str:
|
||||
labels = (
|
||||
"post title",
|
||||
"project name",
|
||||
"snippet name",
|
||||
"title",
|
||||
"name",
|
||||
"concept",
|
||||
"snippet",
|
||||
"headline",
|
||||
"gist",
|
||||
"bug",
|
||||
)
|
||||
result = (text or "").strip().strip('"').strip("'").strip()
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
lowered = result.lower()
|
||||
for label in labels:
|
||||
if lowered.startswith(label + ":"):
|
||||
result = result[len(label) + 1 :].strip().strip('"').strip("'")
|
||||
changed = True
|
||||
break
|
||||
for sep in (" concept:", " description:", " desc:", " idea:"):
|
||||
idx = result.lower().find(sep)
|
||||
if idx > 0:
|
||||
result = result[:idx].strip()
|
||||
return result.strip().strip('"').strip("'").strip()
|
||||
|
||||
@staticmethod
|
||||
def strip_code_fences(text: str) -> str:
|
||||
text = text.strip()
|
||||
@@ -154,16 +192,33 @@ class LLMClient:
|
||||
)
|
||||
return self.clean(text, preserve_md=preserve)
|
||||
|
||||
def select_category(self, title: str, desc: str) -> str:
|
||||
text = self._call(
|
||||
"You are a classifier. Given tech news, choose the single best post category. Reply with ONLY the category name.",
|
||||
f"Categories:\n- devlog: learning journey, building something, personal dev experience\n- showcase: impressive release, new tool, achievement worth highlighting\n- question: uncertainty, asking for advice, curiosity about implications\n- rant: frustration, bad practices, criticism of industry trends\n- fun: amusing, surprising, entertaining but not deeply serious\n- random: anything else that doesn't clearly fit above\n\nNews: {title}\n\n{desc[:600]}",
|
||||
temperature=0.2,
|
||||
def generate_post_title(
|
||||
self, headline: str, persona: str = "", category: str = ""
|
||||
) -> str:
|
||||
style = {
|
||||
"enthusiastic_junior": "Sound excited and curious.",
|
||||
"grumpy_senior": "Sound blunt and a little skeptical.",
|
||||
"hobbyist_maker": "Sound casual and hands-on.",
|
||||
"academic_type": "Sound precise and measured.",
|
||||
"minimalist": "Keep it plain and very short.",
|
||||
"storyteller": "Hint at a story or an angle.",
|
||||
"rebel": "Sound provocative or contrarian.",
|
||||
"mentor": "Sound thoughtful and constructive.",
|
||||
}.get(persona, "")
|
||||
title = self.strip_label(
|
||||
self.clean(
|
||||
self._call(
|
||||
"You are a developer writing the title of a community forum post reacting to tech news. "
|
||||
"Write a natural, human title of 3 to 8 words in your own voice. "
|
||||
"Do not copy or paraphrase the full headline and do not restate every detail. "
|
||||
"No trailing punctuation unless it is a genuine question. No quotes. No label prefix. "
|
||||
f"No em dashes. {style}",
|
||||
f"Headline: {headline}\nYour post title:",
|
||||
temperature=0.8,
|
||||
)
|
||||
)
|
||||
)
|
||||
for c in CATEGORIES:
|
||||
if c in text.lower():
|
||||
return c
|
||||
return "random"
|
||||
return title[:120]
|
||||
|
||||
def select_reaction(self, content_snippet: str, persona: str = "") -> str:
|
||||
from devplacepy.constants import REACTION_EMOJI
|
||||
@@ -334,40 +389,85 @@ class LLMClient:
|
||||
)
|
||||
)[:500]
|
||||
|
||||
def generate_project_title(self) -> str:
|
||||
return self._call(
|
||||
"Come up with a project name. 2-4 words. A tool, game, or app idea. No em dashes.",
|
||||
"Name:",
|
||||
temperature=0.9,
|
||||
)
|
||||
def generate_project_title(self, persona: str = "") -> str:
|
||||
return self.strip_label(
|
||||
self.clean(
|
||||
self._call(
|
||||
"Invent a short, catchy project name. 2 to 4 words. A tool, game, or app. "
|
||||
"Output only the name. No label, no colon, no description, no quotes. No em dashes.",
|
||||
"Project name:",
|
||||
temperature=0.9,
|
||||
)
|
||||
)
|
||||
)[:80]
|
||||
|
||||
def generate_project_desc(self, title: str) -> str:
|
||||
return self._call(
|
||||
"You are a developer. Write a compelling 2-3 sentence project description including tech stack and purpose.",
|
||||
f"Project: {title}",
|
||||
)
|
||||
def generate_project_desc(self, title: str, persona: str = "") -> str:
|
||||
flavor = {
|
||||
"grumpy_senior": "Keep it dry and matter of fact.",
|
||||
"academic_type": "Be precise about the approach and trade-offs.",
|
||||
"minimalist": "Two short sentences, no filler.",
|
||||
"rebel": "Be bold about why the usual approach is wrong.",
|
||||
"storyteller": "Open with the motivation behind it.",
|
||||
}.get(persona, "")
|
||||
return self.clean(
|
||||
self._call(
|
||||
"You are a developer. Write a compelling 2 to 3 sentence project description "
|
||||
f"including the tech stack and purpose. {flavor} No em dashes.",
|
||||
f"Project: {title}",
|
||||
)
|
||||
)[:5000]
|
||||
|
||||
def generate_gist(self, persona: str = "") -> tuple[str, str, str, str]:
|
||||
language = random.choice(GIST_LANGUAGES)
|
||||
title = self.clean(
|
||||
languages = PERSONA_LANGUAGES.get(persona) or GIST_LANGUAGES
|
||||
language = random.choice(languages)
|
||||
flavor = PERSONA_GIST_FLAVOR.get(persona, "a genuinely useful utility")
|
||||
code = self.strip_code_fences(
|
||||
self._call(
|
||||
"Name a short, useful code snippet. 2-5 words. No quotes. No markdown.",
|
||||
f"Language: {language}. Snippet name:",
|
||||
temperature=0.9,
|
||||
f"Write a short, correct, self-contained {language} snippet of 8 to 20 lines. "
|
||||
f"It should be {flavor}. Make it non-trivial and genuinely useful: no hello world, "
|
||||
"no bare language-feature demo, no textbook 101 example, no trivial one-liner, "
|
||||
"no basic getter or setter. Output ONLY raw code. No markdown fences. No commentary.",
|
||||
f"Language: {language}. Write the snippet:",
|
||||
temperature=0.7,
|
||||
)
|
||||
)[:4000]
|
||||
title = self.strip_label(
|
||||
self.clean(
|
||||
self._call(
|
||||
"Name this code snippet in 2 to 5 words, like a developer titling a gist. "
|
||||
"No quotes. No markdown. No label prefix. No em dashes.",
|
||||
f"Language: {language}\nCode:\n{code[:1200]}\nTitle:",
|
||||
temperature=0.7,
|
||||
)
|
||||
)
|
||||
)[:120]
|
||||
description = self.clean(
|
||||
self._call(
|
||||
"Write a one-sentence description of what a code snippet does, like a dev sharing something handy. No em dashes.",
|
||||
f"Snippet: {title}\nLanguage: {language}",
|
||||
"Write a one-sentence description of what this snippet does and when it is handy. No em dashes.",
|
||||
f"Title: {title}\nLanguage: {language}\nCode:\n{code[:1200]}",
|
||||
)
|
||||
)[:400]
|
||||
code = self.strip_code_fences(
|
||||
self._call(
|
||||
f"Write a short, correct, self-contained {language} snippet of 5 to 20 lines for the description. "
|
||||
"Output ONLY raw code. No markdown fences. No commentary.",
|
||||
f"Title: {title}\nDescription: {description}\nLanguage: {language}",
|
||||
temperature=0.4,
|
||||
)
|
||||
)[:4000]
|
||||
return title, description, language, code
|
||||
|
||||
def gist_quality_check(
|
||||
self, title: str, code: str, language: str
|
||||
) -> tuple[bool, str]:
|
||||
lowered = (title or "").lower()
|
||||
for term in TRIVIAL_GIST_TERMS:
|
||||
if term in lowered:
|
||||
return False, f"trivial topic '{term}'"
|
||||
lines = [ln for ln in (code or "").splitlines() if ln.strip()]
|
||||
if len(lines) < self.gist_min_lines:
|
||||
return False, f"too few lines ({len(lines)} < {self.gist_min_lines})"
|
||||
verdict = self._call(
|
||||
"You are a strict reviewer for a developer community's shared code snippets. "
|
||||
"Reject snippets that are textbook 101 material, trivial one-liners, hello world, "
|
||||
"a bare language-feature demo, or something every developer already knows by heart. "
|
||||
"Accept only snippets an experienced developer would find non-obvious or worth bookmarking. "
|
||||
"Reply with exactly PASS or 'FAIL: <short reason>'.",
|
||||
f"Language: {language}\nTitle: {title}\nCode:\n{code[:1500]}",
|
||||
temperature=0.0,
|
||||
)
|
||||
if verdict.strip().upper().startswith("PASS"):
|
||||
return True, "ok"
|
||||
return False, verdict.strip()[:120] or "judge rejected"
|
||||
|
||||
@@ -7,12 +7,36 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
ARTICLE_TTL_DAYS,
|
||||
MAX_BOTS_PER_ARTICLE,
|
||||
persona_article_score,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArticleRegistry:
|
||||
def __init__(self, path: Path):
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
max_per_article: int = MAX_BOTS_PER_ARTICLE,
|
||||
ttl_days: int = ARTICLE_TTL_DAYS,
|
||||
):
|
||||
self.path = path
|
||||
self.max_per_article = max(1, max_per_article)
|
||||
self.ttl_days = max(1, ttl_days)
|
||||
|
||||
@staticmethod
|
||||
def _normalize(meta: dict) -> dict:
|
||||
if "holders" in meta and isinstance(meta["holders"], list):
|
||||
return meta
|
||||
bot = meta.get("bot", "")
|
||||
time = meta.get("time", "")
|
||||
return {
|
||||
"holders": [{"bot": bot, "category": "", "time": time}],
|
||||
"time": time,
|
||||
}
|
||||
|
||||
def _lock(self) -> tuple:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -40,6 +64,7 @@ class ArticleRegistry:
|
||||
for title, meta in data.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
meta = self._normalize(meta)
|
||||
ts = meta.get("time", "")
|
||||
try:
|
||||
age_days = (
|
||||
@@ -47,7 +72,7 @@ class ArticleRegistry:
|
||||
).total_seconds() / 86400
|
||||
except (ValueError, TypeError):
|
||||
age_days = 0
|
||||
if age_days < 7:
|
||||
if age_days < self.ttl_days:
|
||||
kept[title] = meta
|
||||
return fd, kept
|
||||
except Exception as e:
|
||||
@@ -63,34 +88,78 @@ class ArticleRegistry:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to unlock article registry: %s", e)
|
||||
|
||||
def reserve(self, title: str, owner: str) -> bool:
|
||||
def _admit(self, entry: Optional[dict], owner: str, category: str) -> bool:
|
||||
if entry is None:
|
||||
return True
|
||||
holders = entry.get("holders", [])
|
||||
if any(h.get("bot") == owner for h in holders):
|
||||
return False
|
||||
if len(holders) >= self.max_per_article:
|
||||
return False
|
||||
if category and any(h.get("category") == category for h in holders):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _hold(registry: dict, title: str, owner: str, category: str) -> None:
|
||||
now = datetime.now().isoformat()
|
||||
entry = registry.get(title)
|
||||
if entry is None:
|
||||
registry[title] = {
|
||||
"holders": [{"bot": owner, "category": category, "time": now}],
|
||||
"time": now,
|
||||
}
|
||||
return
|
||||
entry["holders"].append({"bot": owner, "category": category, "time": now})
|
||||
entry["time"] = now
|
||||
|
||||
def reserve(self, title: str, owner: str, category: str = "") -> bool:
|
||||
fd, registry = self._lock()
|
||||
if fd is None:
|
||||
return True
|
||||
try:
|
||||
if title in registry:
|
||||
entry = registry.get(title)
|
||||
if entry is not None and any(
|
||||
h.get("bot") == owner for h in entry.get("holders", [])
|
||||
):
|
||||
return True
|
||||
if not self._admit(entry, owner, category):
|
||||
return False
|
||||
registry[title] = {"bot": owner, "time": datetime.now().isoformat()}
|
||||
self._hold(registry, title, owner, category)
|
||||
self.path.write_text(json.dumps(registry, indent=2, default=str))
|
||||
return True
|
||||
finally:
|
||||
self._unlock(fd)
|
||||
|
||||
def reserve_unused(self, articles: list[dict], owner: str, clean) -> Optional[dict]:
|
||||
def reserve_unused(
|
||||
self,
|
||||
articles: list[dict],
|
||||
owner: str,
|
||||
clean,
|
||||
category: str = "",
|
||||
persona: str = "",
|
||||
) -> Optional[dict]:
|
||||
if not articles:
|
||||
return None
|
||||
fd, registry = self._lock()
|
||||
if fd is None:
|
||||
return random.choice(articles) if articles else None
|
||||
return articles[0]
|
||||
try:
|
||||
used = set(registry.keys())
|
||||
random.shuffle(articles)
|
||||
for a in articles:
|
||||
ranked = sorted(
|
||||
articles,
|
||||
key=lambda a: persona_article_score(a, persona) + random.random(),
|
||||
reverse=True,
|
||||
)
|
||||
for a in ranked:
|
||||
title = clean(a.get("title", "")[:200])
|
||||
if title and title not in used:
|
||||
registry[title] = {"bot": owner, "time": datetime.now().isoformat()}
|
||||
self.path.write_text(json.dumps(registry, indent=2, default=str))
|
||||
return a
|
||||
if not title:
|
||||
continue
|
||||
entry = registry.get(title)
|
||||
if not self._admit(entry, owner, category):
|
||||
continue
|
||||
self._hold(registry, title, owner, category)
|
||||
self.path.write_text(json.dumps(registry, indent=2, default=str))
|
||||
return a
|
||||
return None
|
||||
finally:
|
||||
self._unlock(fd)
|
||||
|
||||
@@ -16,3 +16,9 @@ class BotRuntimeConfig:
|
||||
output_cost_per_1m: float = config.OUTPUT_COST_PER_1M_DEFAULT
|
||||
headless: bool = True
|
||||
registry_path: Path = config.ARTICLE_REGISTRY_PATH
|
||||
max_per_article: int = config.MAX_BOTS_PER_ARTICLE
|
||||
article_ttl_days: int = config.ARTICLE_TTL_DAYS
|
||||
gist_min_lines: int = config.GIST_MIN_LINES
|
||||
action_pause_min: int = config.ACTION_PAUSE_MIN_SECONDS
|
||||
action_pause_max: int = config.ACTION_PAUSE_MAX_SECONDS
|
||||
break_scale: float = config.BREAK_SCALE_DEFAULT
|
||||
|
||||
@@ -107,6 +107,66 @@ class BotsService(BaseService):
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_max_per_article",
|
||||
"Bots per article",
|
||||
type="int",
|
||||
default=config.MAX_BOTS_PER_ARTICLE,
|
||||
minimum=1,
|
||||
maximum=5,
|
||||
help="How many bots may post about the same news article, each from a different angle.",
|
||||
group="Behavior",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_article_ttl_days",
|
||||
"Article cooldown (days)",
|
||||
type="int",
|
||||
default=config.ARTICLE_TTL_DAYS,
|
||||
minimum=1,
|
||||
maximum=90,
|
||||
help="How long an article stays covered before it can be posted about again.",
|
||||
group="Behavior",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_gist_min_lines",
|
||||
"Minimum gist lines",
|
||||
type="int",
|
||||
default=config.GIST_MIN_LINES,
|
||||
minimum=1,
|
||||
maximum=50,
|
||||
help="Reject generated code snippets shorter than this many non-empty lines.",
|
||||
group="Behavior",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_action_pause_min_seconds",
|
||||
"Min pause between actions (s)",
|
||||
type="int",
|
||||
default=config.ACTION_PAUSE_MIN_SECONDS,
|
||||
minimum=0,
|
||||
maximum=3600,
|
||||
help="Lower bound of the idle pause a bot takes after each action.",
|
||||
group="Pacing",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_action_pause_max_seconds",
|
||||
"Max pause between actions (s)",
|
||||
type="int",
|
||||
default=config.ACTION_PAUSE_MAX_SECONDS,
|
||||
minimum=1,
|
||||
maximum=7200,
|
||||
help="Upper bound of the idle pause a bot takes after each action.",
|
||||
group="Pacing",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_break_scale",
|
||||
"Break length multiplier",
|
||||
type="float",
|
||||
default=config.BREAK_SCALE_DEFAULT,
|
||||
minimum=0.1,
|
||||
maximum=10.0,
|
||||
help="Scales the between-session break. Below 1 makes the fleet more active (and costlier); above 1 calmer.",
|
||||
group="Pacing",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -183,6 +243,12 @@ class BotsService(BaseService):
|
||||
output_cost_per_1m=cfg["bot_output_cost_per_1m"],
|
||||
headless=cfg["bot_headless"],
|
||||
registry_path=config.ARTICLE_REGISTRY_PATH,
|
||||
max_per_article=cfg["bot_max_per_article"],
|
||||
article_ttl_days=cfg["bot_article_ttl_days"],
|
||||
gist_min_lines=cfg["bot_gist_min_lines"],
|
||||
action_pause_min=cfg["bot_action_pause_min_seconds"],
|
||||
action_pause_max=cfg["bot_action_pause_max_seconds"],
|
||||
break_scale=cfg["bot_break_scale"],
|
||||
)
|
||||
bot = DevPlaceBot(rc, on_event=self.log)
|
||||
task = asyncio.create_task(self._run_slot(slot, bot, cfg["bot_max_actions"]))
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
et nocompatible
|
||||
filetype off
|
||||
set rtp+=~/.vim/bundle/Vundle.vim
|
||||
" retoor <retoor@molodetz.nl>
|
||||
" Self-contained config for the ppy container. No external plugins or managers:
|
||||
" it works out of the box with the stock vim, and the AI edit feature uses only
|
||||
" curl and the PRAVDA_* gateway env that every instance is launched with.
|
||||
|
||||
let g:ycm_auto_trigger = 1
|
||||
|
||||
filetype plugin indent on " required
|
||||
|
||||
set clipboard=unnamedplus
|
||||
|
||||
function! GitBranch()
|
||||
let l:branch = system("bash -c 'uptime'")
|
||||
return l:branch
|
||||
endfunction
|
||||
|
||||
call plug#begin()
|
||||
" List your plugins here
|
||||
Plug 'prabirshrestha/asyncomplete.vim'
|
||||
Plug 'prabirshrestha/asyncomplete-lsp.vim'
|
||||
Plug 'tpope/vim-sensible'
|
||||
Plug 'Exafunction/codeium.vim', { 'branch': 'main' }
|
||||
Plug 'prabirshrestha/vim-lsp'
|
||||
Plug 'mattn/vim-lsp-settings'
|
||||
set statusline=\{…\}%3{codeium#GetStatusString()}\%h%m%r\ [%l,%c]\ %p%%
|
||||
highlight StatusLine guifg=#ffffff guibg=#005f87
|
||||
highlight StatusLineNC guifg=#aaaaaa guibg=#303030
|
||||
highlight ErrorMsg ctermfg=Red ctermbg=NONE guifg=#FF0000 guibg=NONE
|
||||
|
||||
call plug#end()
|
||||
|
||||
call vundle#begin()
|
||||
Plugin 'VundleVim/Vundle.vim'
|
||||
Plugin 'ycm-core/YouCompleteMe'
|
||||
call vundle#end()
|
||||
set nocompatible
|
||||
filetype plugin indent on
|
||||
syntax on
|
||||
|
||||
set encoding=utf-8
|
||||
set fileencoding=utf-8
|
||||
set termencoding=utf-8
|
||||
set mouse=a
|
||||
set backspace=indent,eol,start
|
||||
syntax on
|
||||
filetype plugin indent on
|
||||
|
||||
set showtabline=2
|
||||
set enc=utf-8
|
||||
set fenc=utf-8
|
||||
set termencoding=utf-8
|
||||
set autoindent
|
||||
set smartindent
|
||||
set tabstop=4
|
||||
@@ -49,197 +19,101 @@ set shiftwidth=4
|
||||
set expandtab
|
||||
set number
|
||||
set showmatch
|
||||
set comments=sl:/*,mb:\ *,elx:\ */
|
||||
set showtabline=2
|
||||
set laststatus=2
|
||||
set hidden
|
||||
set incsearch
|
||||
set hlsearch
|
||||
set wildmenu
|
||||
set ttimeoutlen=50
|
||||
|
||||
let mapleader = ","
|
||||
|
||||
function! ShowInTerminal(content)
|
||||
" Open a new terminal split
|
||||
belowright split | terminal
|
||||
" Wait for terminal to initialize
|
||||
call feedkeys("i") " go into insert mode to send keys
|
||||
call chansend(b:terminal_job_id, a:content . "\n")
|
||||
endfunction
|
||||
|
||||
function! AIR()
|
||||
let l:ai_prompt = input("Enter AI instructions: ")
|
||||
let $AI_PROMPT = l:ai_prompt
|
||||
let l:result = system('r ' . l:ai_prompt)
|
||||
call ShowInTerminal(l:result)
|
||||
|
||||
endfunction
|
||||
|
||||
function! AIPromptSelection()
|
||||
" Save cursor position and the unnamed register
|
||||
let l:pos = getpos('.')
|
||||
let l:save_reg = @"
|
||||
let l:save_regtype = getregtype('"')
|
||||
|
||||
" Yank the visual selection into the unnamed register
|
||||
normal! gvy
|
||||
let l:selected_text = @"
|
||||
|
||||
" Restore the unnamed register
|
||||
call setreg('"', l:save_reg, l:save_regtype)
|
||||
|
||||
" Prompt the user for AI instructions
|
||||
let l:ai_prompt = input('Enter AI instructions: ')
|
||||
let $AI_PROMPT = l:ai_prompt
|
||||
|
||||
" Call your external AI formatter (replace `c` with your command)
|
||||
let l:formatted = system('c', l:selected_text)
|
||||
|
||||
" Handle errors or replace the selection with the AI response
|
||||
if v:shell_error
|
||||
echohl ErrorMsg | echom 'Formatting failed' | echohl None
|
||||
else
|
||||
" Delete the original selection
|
||||
normal! gvd
|
||||
" Insert the formatted text *before* the cursor (same spot)
|
||||
put! =l:formatted
|
||||
" Restore the original cursor position
|
||||
call setpos('.', l:pos)
|
||||
endif
|
||||
|
||||
" Restore the unnamed register again (good measure)
|
||||
call setreg('"', l:save_reg, l:save_regtype)
|
||||
endfunction
|
||||
|
||||
" Map the AI prompt function to a key combination
|
||||
vnoremap <Leader>f :<C-u>call AIPromptSelection()<CR>
|
||||
nnoremap <Leader>r :call AIR()<CR>
|
||||
|
||||
" Existing keymappings and other configurations...
|
||||
inoremap <C-n> <ESC>:tabnext<CR>
|
||||
inoremap <C-p> <ESC>:tabnext<CR>
|
||||
nnoremap <C-n> :tabnext<CR>
|
||||
nnoremap <C-p> :tabnext<CR>
|
||||
nnoremap <Tab> :tabnext<CR>
|
||||
nnoremap <C-Tab> :tabprevious<CR>
|
||||
inoremap <C-t> <ESC>:terminal<CR><CR><C-w>r<CR><C-w>-<C-w>-<C-w>-<C-w>-<C-w>-<C-w>-<C-w>-
|
||||
nnoremap <C-e> :tabnew<Space>
|
||||
inoremap <C-e> <ESC>:tabnew<Space>
|
||||
nnoremap e :tabnew<Space>
|
||||
nnoremap q <ESC>:q<CR>
|
||||
if has('clipboard')
|
||||
set clipboard=unnamedplus
|
||||
endif
|
||||
|
||||
if !isdirectory(expand('~/.vim/undo'))
|
||||
call mkdir(expand('~/.vim/undo'), 'p')
|
||||
endif
|
||||
set undofile
|
||||
set undodir=~/.vim/undo
|
||||
|
||||
" Existing commands and other configurations...
|
||||
command! GPT py3file ~/bin/gpt
|
||||
command! Refactor py3file /home/retoor/.vim/plugin/refactor.py
|
||||
command! HelloVim py3file ~/.vim/plugin/hello.py | py3 say_hello()
|
||||
set statusline=%f\ %h%m%r\ %=\ [%{&filetype}]\ [%l,%c]\ %p%%
|
||||
highlight StatusLine cterm=bold ctermfg=15 ctermbg=24
|
||||
highlight StatusLineNC cterm=none ctermfg=250 ctermbg=236
|
||||
highlight ErrorMsg cterm=bold ctermfg=red ctermbg=none
|
||||
|
||||
if has("autocmd")
|
||||
au BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
|
||||
let mapleader = ","
|
||||
|
||||
inoremap <C-n> <ESC>:tabnext<CR>
|
||||
inoremap <C-p> <ESC>:tabprevious<CR>
|
||||
nnoremap <C-n> :tabnext<CR>
|
||||
nnoremap <C-p> :tabprevious<CR>
|
||||
nnoremap <Tab> :tabnext<CR>
|
||||
nnoremap <C-Tab> :tabprevious<CR>
|
||||
nnoremap <C-e> :tabnew<Space>
|
||||
inoremap <C-e> <ESC>:tabnew<Space>
|
||||
|
||||
if has('autocmd')
|
||||
autocmd BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
|
||||
endif
|
||||
|
||||
execute pathogen#infect()
|
||||
execute pathogen#helptags()
|
||||
|
||||
" Helper: get visually selected text
|
||||
function! GetVisualSelection()
|
||||
let [line_start, col_start] = [line("'<"), col("'<")]
|
||||
let [line_end, col_end] = [line("'>"), col("'>")]
|
||||
let lines = getline(line_start, line_end)
|
||||
if len(lines) == 0
|
||||
function! s:GetVisualSelection() abort
|
||||
let [l:line_start, l:col_start] = [line("'<"), col("'<")]
|
||||
let [l:line_end, l:col_end] = [line("'>"), col("'>")]
|
||||
let l:lines = getline(l:line_start, l:line_end)
|
||||
if empty(l:lines)
|
||||
return ''
|
||||
endif
|
||||
" Trim col for first/last line if selection is characterwise
|
||||
let lines[0] = lines[0][col_start - 1 :]
|
||||
let lines[-1] = lines[-1][: col_end - (line_start == line_end ? 1 : 2)]
|
||||
return join(lines, "\n")
|
||||
let l:lines[-1] = l:lines[-1][: l:col_end - (l:line_start == l:line_end ? 1 : 2)]
|
||||
let l:lines[0] = l:lines[0][l:col_start - 1 :]
|
||||
return join(l:lines, "\n")
|
||||
endfunction
|
||||
|
||||
function! AiEditSelection()
|
||||
" (1) Get user prompt
|
||||
let l:instruction = input("AI instruction: ")
|
||||
if empty(l:instruction)
|
||||
echo "No instruction given, cancelled."
|
||||
return
|
||||
endif
|
||||
|
||||
" (2) Get visual selection
|
||||
let l:origText = GetVisualSelection()
|
||||
if empty(l:origText)
|
||||
echo "No selection."
|
||||
return
|
||||
endif
|
||||
|
||||
" (3) Compose the API prompt
|
||||
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:origText . "\n\nOutput only the transformed text. Do not include explanations, markdown, or code blocks."
|
||||
|
||||
" (4) Send to the same gateway pagent uses (PRAVDA_OPENAI_URL / PRAVDA_API_KEY)
|
||||
function! s:GatewayUrl() abort
|
||||
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
|
||||
if empty(l:base)
|
||||
let l:url = 'https://openai.app.molodetz.nl/v1/chat/completions'
|
||||
return 'https://openai.app.molodetz.nl/v1/chat/completions'
|
||||
elseif l:base =~# '/chat/completions$'
|
||||
let l:url = l:base
|
||||
else
|
||||
let l:url = l:base . '/chat/completions'
|
||||
return l:base
|
||||
endif
|
||||
return l:base . '/chat/completions'
|
||||
endfunction
|
||||
|
||||
function! AiEditSelection() abort
|
||||
let l:instruction = input('AI instruction: ')
|
||||
if empty(l:instruction)
|
||||
echo 'Cancelled.'
|
||||
return
|
||||
endif
|
||||
let l:orig = s:GetVisualSelection()
|
||||
if empty(l:orig)
|
||||
echo 'No selection.'
|
||||
return
|
||||
endif
|
||||
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
|
||||
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
|
||||
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
|
||||
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":'.json_encode(l:prompt).'}]}'
|
||||
let l:cmd = 'curl -sS -X POST ' . shellescape(l:url) . ' -H "Authorization: Bearer ' . l:api_key . '" -H "Content-Type: application/json" -d ' . shellescape(l:json)
|
||||
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
|
||||
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
|
||||
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
|
||||
\ . ' -H ' . shellescape('Content-Type: application/json')
|
||||
\ . ' -d ' . shellescape(l:json)
|
||||
let l:reply = system(l:cmd)
|
||||
" --- Extract output (simple JSON parsing) ---
|
||||
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*}')
|
||||
if v:shell_error
|
||||
echohl ErrorMsg | echom 'AI request failed' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*[,}]')
|
||||
if empty(l:text)
|
||||
echohl ErrorMsg | echom 'No content in AI response' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = substitute(l:text, '\\n', "\n", 'g')
|
||||
let l:text = substitute(l:text, '\\"', '"', 'g')
|
||||
|
||||
" (5) Replace selected text
|
||||
let l:text = substitute(l:text, '\\t', "\t", 'g')
|
||||
normal! gv
|
||||
" Use c to change or d (delete and then 'put') depending on visual mode
|
||||
normal! c
|
||||
call feedkeys(l:text, 'n')
|
||||
endfunction
|
||||
|
||||
" Visual mode mapping
|
||||
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>
|
||||
|
||||
command! GreetPython py3 greet()
|
||||
vnoremap <leader>gr :GPTRefactor<CR>
|
||||
vnoremap <leader>gd :GPTRefactorDefault<CR>
|
||||
|
||||
|
||||
if executable('pylsp')
|
||||
" pip install python-lsp-server
|
||||
au User lsp_setup call lsp#register_server({
|
||||
\ 'name': 'pylsp',
|
||||
\ 'cmd': {server_info->['pylsp']},
|
||||
\ 'allowlist': ['python'],
|
||||
\ })
|
||||
endif
|
||||
|
||||
function! s:on_lsp_buffer_enabled() abort
|
||||
setlocal omnifunc=lsp#complete
|
||||
setlocal signcolumn=yes
|
||||
if exists('+tagfunc') | setlocal tagfunc=lsp#tagfunc | endif
|
||||
nmap <buffer> gd <plug>(lsp-definition)
|
||||
nmap <buffer> gs <plug>(lsp-document-symbol-search)
|
||||
nmap <buffer> gS <plug>(lsp-workspace-symbol-search)
|
||||
nmap <buffer> gr <plug>(lsp-references)
|
||||
nmap <buffer> gi <plug>(lsp-implementation)
|
||||
nmap <buffer> gt <plug>(lsp-type-definition)
|
||||
nmap <buffer> <leader>rn <plug>(lsp-rename)
|
||||
nmap <buffer> [g <plug>(lsp-previous-diagnostic)
|
||||
nmap <buffer> ]g <plug>(lsp-next-diagnostic)
|
||||
nmap <buffer> K <plug>(lsp-hover)
|
||||
nnoremap <buffer> <expr><c-f> lsp#scroll(+4)
|
||||
nnoremap <buffer> <expr><c-d> lsp#scroll(-4)
|
||||
|
||||
let g:lsp_format_sync_timeout = 1000
|
||||
autocmd! BufWritePre *.rs,*.go call execute('LspDocumentFormatSync')
|
||||
|
||||
" refer to doc to add more commands
|
||||
endfunction
|
||||
|
||||
augroup lsp_install
|
||||
au!
|
||||
" call s:on_lsp_buffer_enabled only for languages that has the server registered.
|
||||
autocmd User lsp_buffer_enabled call s:on_lsp_buffer_enabled()
|
||||
augroup END
|
||||
|
||||
let g:lsp_document_highlight_enabled = 1
|
||||
let g:lsp_diagnostics_enabled = 0 " disable diagnostics support
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# devplace aptroot: lets pravda (uid 1000) run apt / apt-get / dpkg with full
|
||||
# package-manager power, without real root and without sudo. It runs the real
|
||||
# tool under fakeroot, so every chown-to-root dpkg performs is virtualized and
|
||||
# every file written to disk stays owned by pravda (uid 1000). Combined with
|
||||
# pravda owning the system trees, `apt install <pkg>` just works, and nothing
|
||||
# can ever create a root-owned file on the host bind mount.
|
||||
|
||||
_dp_tool="$(basename "$0")"
|
||||
_dp_real="/usr/bin/${_dp_tool}"
|
||||
|
||||
if [ ! -x "$_dp_real" ]; then
|
||||
echo "aptroot: real ${_dp_tool} not found at ${_dp_real}" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
case "$_dp_tool" in
|
||||
apt|apt-get)
|
||||
exec fakeroot "$_dp_real" \
|
||||
-o APT::Sandbox::User=root \
|
||||
-o Dpkg::Use-Pty=0 \
|
||||
"$@"
|
||||
;;
|
||||
*)
|
||||
exec fakeroot "$_dp_real" "$@"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,33 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
BEHAVIOR_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="update_behavior",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Update your own persistent '# TRUTH RULES AND BEHAVIOR' section",
|
||||
description=(
|
||||
"Records how the user wants you to behave into the '# TRUTH RULES AND BEHAVIOR' section at "
|
||||
"the end of your system message, so the change persists across turns and restarts. Call it "
|
||||
"when the user tells you to behave differently, says they expect different behavior, or you "
|
||||
"upset them. The 'behavior' value is the FULL new content of that section: take the rules "
|
||||
"currently shown there, apply the user's change (add, adjust, or remove a rule), and pass "
|
||||
"the whole result so nothing already learned is lost unless they want it removed. Private "
|
||||
"to this account (a guest's applies to the current session only)."
|
||||
),
|
||||
handler="behavior",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(
|
||||
name="behavior",
|
||||
location="body",
|
||||
description="The full new content of the '# TRUTH RULES AND BEHAVIOR' section.",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -141,6 +141,214 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="discover_elements",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the actionable elements on the user's screen in reading order, each with a stable ref, role, label, selector, and state",
|
||||
description=(
|
||||
CLIENT
|
||||
+ " This is how you SEE the page before acting: it returns buttons, links, inputs, "
|
||||
"textboxes, checkboxes, selects, tabs, and menu items in document (logical) order, each "
|
||||
"with a `ref` (like e3) you can pass to click_element/fill_field/etc, plus its role, "
|
||||
"accessible label, a robust CSS selector, current state (visible, enabled, checked, value), "
|
||||
"and which form or modal it belongs to. Call this first, then act on the refs. Use `query` "
|
||||
"to filter by label, `within` to scope to a region (a ref/selector, e.g. an open modal), and "
|
||||
"`kind` to keep only one type."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
arg("query", "Only return elements whose label contains this text (case-insensitive)."),
|
||||
arg(
|
||||
"within",
|
||||
"Scope discovery to inside this element (a ref, CSS selector, or exact text), e.g. an open modal or form.",
|
||||
),
|
||||
arg(
|
||||
"kind",
|
||||
"Keep only one type: button, link, input, textbox, checkbox, radio, select, tab, or menuitem.",
|
||||
),
|
||||
arg("limit", "Maximum number of elements to return (default 40).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="read_element",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read one element in detail: text, value, attributes, state, position, and visibility",
|
||||
description=CLIENT
|
||||
+ " Use this to investigate a specific element you found with discover_elements, "
|
||||
"or to confirm the result of an action (its value, whether it is checked, any error text near it).",
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
arg(
|
||||
"target",
|
||||
"The element to read: a ref (e3), a CSS selector, or its exact visible text.",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="click_element",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Click an element on the user's screen and report what changed",
|
||||
description=(
|
||||
CLIENT
|
||||
+ " Scrolls the element into view, waits briefly for it to be visible and enabled, "
|
||||
"dispatches a real click, and returns a delta (did the URL change, did a modal open or close, "
|
||||
"any new toast or validation error, is the element still there). Prefer this over run_js for clicking. "
|
||||
"Set `button` to 'right' for a context menu or 'double' for a double-click."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg(
|
||||
"target",
|
||||
"The element to click: a ref (e3), a CSS selector, or its exact visible text.",
|
||||
required=True,
|
||||
),
|
||||
arg("button", "left (default), right, or double."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="fill_field",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Type a value into an input, textarea, contenteditable, or code editor and fire the right events",
|
||||
description=(
|
||||
CLIENT
|
||||
+ " Sets the value through the native setter and dispatches input and change events, so "
|
||||
"framework and validation handlers actually run (plain run_js value assignment does not). "
|
||||
"Supports text inputs, textareas, contenteditable elements, and CodeMirror editors. "
|
||||
"Returns the resulting value and any validation message shown near the field."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg(
|
||||
"target",
|
||||
"The field to fill: a ref (e3), a CSS selector, or its label/placeholder text.",
|
||||
required=True,
|
||||
),
|
||||
arg("value", "The value to type into the field.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="set_control",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Toggle a checkbox or radio, or choose an option in a select",
|
||||
description=CLIENT
|
||||
+ " For a checkbox or radio pass `checked` true/false; for a select pass `option` "
|
||||
"(matched against option value or visible label). Dispatches a change event.",
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg(
|
||||
"target",
|
||||
"The control: a ref (e3), a CSS selector, or its label text.",
|
||||
required=True,
|
||||
),
|
||||
arg("checked", "For a checkbox or radio: true to check, false to uncheck.", kind="boolean"),
|
||||
arg("option", "For a select: the option value or visible label to choose."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="submit_form",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Optionally fill a form's fields, then submit it, and report what changed",
|
||||
description=(
|
||||
CLIENT
|
||||
+ " Resolves the form that contains `target` (a ref/selector/text for the form, a field in it, "
|
||||
"or its submit button). If `fields` is given (a map of field name/label to value) each is filled "
|
||||
"first with the proper events, then the form is submitted by clicking its submit button (so app "
|
||||
"handlers run) or calling requestSubmit. Returns a delta (URL change, modal open/close, toast, errors). "
|
||||
"This is the primary tool for completing a mutation such as creating a post or saving a profile."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg(
|
||||
"target",
|
||||
"The form, one of its fields, or its submit button: a ref (e3), CSS selector, or exact text.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
"fields",
|
||||
"Optional map of field name or label to the value to fill before submitting.",
|
||||
kind="object",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="wait_for",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Wait until an element becomes visible, hidden, or contains text, before continuing",
|
||||
description=(
|
||||
CLIENT
|
||||
+ " Polls the page until the condition holds or it times out, so sequences after a navigation, "
|
||||
"click, or fetch do not race. `condition` is visible (default), hidden, or text_contains "
|
||||
"(supply `text`). Returns whether it was satisfied and how long it waited."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
arg(
|
||||
"target",
|
||||
"The element to wait on: a ref (e3), a CSS selector, or exact visible text.",
|
||||
required=True,
|
||||
),
|
||||
arg("condition", "visible (default), hidden, or text_contains."),
|
||||
arg("text", "For text_contains: the text the element should contain."),
|
||||
arg("timeout_ms", "Maximum wait in milliseconds (default 8000, capped at 55000).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="press_key",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Send a keypress to an element or the page (Enter, Escape, Tab, arrows, etc.)",
|
||||
description=CLIENT
|
||||
+ " Dispatches keydown/keypress/keyup for the named key to `target` (or the focused element "
|
||||
"if omitted). Use Enter to submit, Escape to close a modal, Tab to move focus.",
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("key", "The key name: Enter, Escape, Tab, ArrowDown, a, etc.", required=True),
|
||||
arg("target", "Optional element to send the key to: a ref (e3), selector, or exact text."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="run_sequence",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Run an ordered list of client steps in one round-trip, stopping on the first failure",
|
||||
description=(
|
||||
CLIENT
|
||||
+ " Each step is an object with an `action` (click_element, fill_field, set_control, submit_form, "
|
||||
"wait_for, press_key, scroll_to_element, read_element, discover_elements, highlight_element, show_toast) "
|
||||
"and its arguments. Steps run in order with the page settling between them; execution stops at the first "
|
||||
"error and returns every step's result plus the final page context. Use this to perform a whole UI flow "
|
||||
"(open a modal, fill it, submit) reliably without a round-trip per step. run_js is not allowed inside a sequence."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg(
|
||||
"steps",
|
||||
"Ordered list of step objects, each with an `action` field and that action's arguments.",
|
||||
required=True,
|
||||
kind="array",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="open_terminal",
|
||||
method="LOCAL",
|
||||
|
||||
@@ -49,13 +49,28 @@ SYSTEM_PROMPT = (
|
||||
"data has the created resource's uid/slug/url. Always reuse those exact slugs/uids/urls for "
|
||||
"follow-up calls instead of constructing them. "
|
||||
"In the web terminal you can act on the user's own screen: get_page_context tells you where "
|
||||
"they are and what they see; run_js executes JavaScript in their browser and returns a value; "
|
||||
"highlight_element, show_toast, scroll_to_element and clear_highlights let you guide them with "
|
||||
"live, on-screen tutorials; navigate_to and reload_page move or refresh their page (their "
|
||||
"session and this conversation persist and reconnect automatically). Read the page context "
|
||||
"before guiding, prefer the dedicated tools over raw run_js, and clear highlights when done. "
|
||||
"they are and what they see; navigate_to and reload_page move or refresh their page; "
|
||||
"highlight_element, show_toast, scroll_to_element and clear_highlights guide them with live, "
|
||||
"on-screen tutorials; run_js executes JavaScript and returns a value. The session and this "
|
||||
"conversation persist and reconnect automatically across navigation. "
|
||||
"Confirm destructive actions with the user first. Schedule autonomous work with "
|
||||
"create_task and related tools. All times are UTC.\n\n"
|
||||
"DRIVING THE BROWSER (the reliable flow)\n"
|
||||
"To act on the user's screen, follow this loop instead of guessing selectors or writing run_js. "
|
||||
"(1) get_page_context to see where they are, the open modal, and visible toasts. "
|
||||
"(2) discover_elements to list the actionable elements in reading order, each with a stable ref "
|
||||
"(e3), role, label, selector, and state; pass `within` to scope to a modal/form, `query` to "
|
||||
"filter by label, `kind` to keep one type. "
|
||||
"(3) Act on the refs: click_element to click, fill_field to type (it fires the proper events, "
|
||||
"unlike a raw value assignment), set_control for checkboxes/radios/selects, submit_form to fill "
|
||||
"and submit a whole form in one call. Every act tool returns a delta (URL change, modal open or "
|
||||
"close, new toast, validation error, element gone) - read it to confirm the result. "
|
||||
"(4) wait_for after anything that triggers an async render (a modal opening, a fetch, a "
|
||||
"navigation) so steps do not race; press_key for Enter/Escape/Tab. "
|
||||
"(5) For a known multi-step flow, run_sequence executes the ordered steps in one round-trip and "
|
||||
"stops at the first failure. "
|
||||
"Prefer these structured tools over run_js; reach for run_js only for something none of them "
|
||||
"cover. read_element inspects one element when investigating. Clear highlights when done.\n\n"
|
||||
"AGGREGATES AND LARGE DATA\n"
|
||||
"For any count, total, or 'how many' / 'how active' question, call site_analytics - it returns "
|
||||
"member totals, active users over 24h/7d/30d, signups, content totals, and top authors in a "
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import BehaviorController
|
||||
from .store import BehaviorStore
|
||||
|
||||
__all__ = ["BehaviorController", "BehaviorStore"]
|
||||
@@ -0,0 +1,35 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
logger = logging.getLogger("devii.behavior")
|
||||
|
||||
|
||||
class BehaviorController:
|
||||
def __init__(self, store: Any) -> None:
|
||||
self._store = store
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "update_behavior":
|
||||
return self._update(arguments)
|
||||
raise ToolInputError(f"Unknown behavior tool: {name}")
|
||||
|
||||
def _update(self, arguments: dict[str, Any]) -> str:
|
||||
behavior = arguments.get("behavior")
|
||||
if behavior is None:
|
||||
raise ToolInputError("'behavior' is required.")
|
||||
self._store.set(str(behavior))
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"saved": True,
|
||||
"note": "Updated your '# TRUTH RULES AND BEHAVIOR' section; it applies from your next turn.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("devii.behavior.store")
|
||||
|
||||
TABLE = "devii_behavior"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class BehaviorStore:
|
||||
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
|
||||
self._db = db
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
self._ensure_indexes()
|
||||
|
||||
def _ensure_indexes(self) -> None:
|
||||
if TABLE not in self._db.tables:
|
||||
return
|
||||
self._db[TABLE].create_index(["owner_kind", "owner_id"])
|
||||
|
||||
@property
|
||||
def _table(self) -> Any:
|
||||
return self._db[TABLE]
|
||||
|
||||
@property
|
||||
def _scope(self) -> dict[str, str]:
|
||||
return {"owner_kind": self._owner_kind, "owner_id": self._owner_id}
|
||||
|
||||
def text(self) -> str:
|
||||
if TABLE not in self._db.tables:
|
||||
return ""
|
||||
row = self._table.find_one(**self._scope)
|
||||
return (row.get("behavior") or "") if row else ""
|
||||
|
||||
def set(self, behavior: str) -> None:
|
||||
self._table.upsert(
|
||||
{**self._scope, "behavior": behavior, "updated_at": _now_iso()},
|
||||
["owner_kind", "owner_id"],
|
||||
)
|
||||
logger.info("Behavior updated owner=%s/%s", self._owner_kind, self._owner_id)
|
||||
@@ -21,12 +21,28 @@ EVAL_DISABLED = {
|
||||
"status": "disabled",
|
||||
"message": "JavaScript execution is disabled for Devii by the administrator.",
|
||||
}
|
||||
CONTROL_DISABLED = {
|
||||
"status": "disabled",
|
||||
"message": "Browser control is disabled for Devii by the administrator.",
|
||||
}
|
||||
|
||||
CONTROL_ACTIONS = frozenset(
|
||||
{
|
||||
"click_element",
|
||||
"fill_field",
|
||||
"set_control",
|
||||
"submit_form",
|
||||
"press_key",
|
||||
"run_sequence",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ClientController:
|
||||
def __init__(self, allow_eval: bool = True) -> None:
|
||||
def __init__(self, allow_eval: bool = True, allow_control: bool = True) -> None:
|
||||
self._request: Optional[RequestSink] = None
|
||||
self._allow_eval = allow_eval
|
||||
self._allow_control = allow_control
|
||||
|
||||
def bind(self, request: RequestSink) -> None:
|
||||
self._request = request
|
||||
@@ -43,6 +59,8 @@ class ClientController:
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "run_js" and not self._allow_eval:
|
||||
return json.dumps(EVAL_DISABLED, ensure_ascii=False)
|
||||
if name in CONTROL_ACTIONS and not self._allow_control:
|
||||
return json.dumps(CONTROL_DISABLED, ensure_ascii=False)
|
||||
if self._request is None:
|
||||
return json.dumps(UNAVAILABLE, ensure_ascii=False)
|
||||
try:
|
||||
|
||||
@@ -63,6 +63,7 @@ class Settings:
|
||||
fetch_max_bytes: int
|
||||
fetch_allow_private: bool
|
||||
allow_eval: bool
|
||||
browser_control: bool
|
||||
rsearch_enabled: bool
|
||||
rsearch_url: str
|
||||
rsearch_timeout_seconds: float
|
||||
@@ -136,6 +137,8 @@ def load_settings() -> Settings:
|
||||
in ("1", "true", "yes", "on"),
|
||||
allow_eval=os.environ.get("DEVII_ALLOW_EVAL", "1").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
browser_control=os.environ.get("DEVII_BROWSER_CONTROL", "1").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
rsearch_enabled=os.environ.get("DEVII_RSEARCH_ENABLED", "1").lower()
|
||||
in ("1", "true", "yes", "on"),
|
||||
rsearch_url=os.environ.get("DEVII_RSEARCH_URL", DEFAULT_RSEARCH_URL).rstrip(
|
||||
@@ -162,6 +165,7 @@ FIELD_PRICE_CACHE_HIT = "devii_price_cache_hit"
|
||||
FIELD_PRICE_CACHE_MISS = "devii_price_cache_miss"
|
||||
FIELD_PRICE_OUTPUT = "devii_price_output"
|
||||
FIELD_ALLOW_EVAL = "devii_allow_eval"
|
||||
FIELD_BROWSER_CONTROL = "devii_browser_control"
|
||||
FIELD_TIMEOUT = "devii_timeout"
|
||||
FIELD_FETCH_TIMEOUT = "devii_fetch_timeout"
|
||||
FIELD_RSEARCH_ENABLED = "devii_rsearch_enabled"
|
||||
@@ -219,6 +223,7 @@ def build_settings(
|
||||
fetch_max_bytes=DEFAULT_FETCH_MAX_BYTES,
|
||||
fetch_allow_private=False,
|
||||
allow_eval=bool(config.get(FIELD_ALLOW_EVAL, True)),
|
||||
browser_control=bool(config.get(FIELD_BROWSER_CONTROL, True)),
|
||||
rsearch_enabled=bool(config.get(FIELD_RSEARCH_ENABLED, True)),
|
||||
rsearch_url=(config.get(FIELD_RSEARCH_URL) or DEFAULT_RSEARCH_URL).rstrip("/"),
|
||||
rsearch_timeout_seconds=float(
|
||||
|
||||
@@ -116,6 +116,16 @@ class DeviiService(BaseService):
|
||||
"Other client tools (navigate, reload, highlight, toast, context) are unaffected.",
|
||||
group="Agent",
|
||||
),
|
||||
ConfigField(
|
||||
config.FIELD_BROWSER_CONTROL,
|
||||
"Allow browser control",
|
||||
type="bool",
|
||||
default=True,
|
||||
help="Let Devii drive the user's own browser with structured tools "
|
||||
"(discover elements, click, fill, submit, wait, sequences). Independent of "
|
||||
"raw JavaScript execution; read-only tools (context, discover, read) are unaffected.",
|
||||
group="Agent",
|
||||
),
|
||||
ConfigField(
|
||||
config.FIELD_USER_DAILY_USD,
|
||||
"Max USD per user / 24h",
|
||||
|
||||
@@ -38,6 +38,32 @@ INTERNAL_PREFIXES = (
|
||||
)
|
||||
BUFFERED_TYPES = ("task", "error", "reply", "status")
|
||||
BEHAVIOR_HEADER = "# TRUTH RULES AND BEHAVIOR"
|
||||
|
||||
|
||||
def _repair_history(messages: list[dict[str, Any]]) -> None:
|
||||
last_idx = None
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
message = messages[i]
|
||||
if message.get("role") == "assistant" and message.get("tool_calls"):
|
||||
last_idx = i
|
||||
break
|
||||
if last_idx is None:
|
||||
while messages and messages[-1].get("role") == "tool":
|
||||
messages.pop()
|
||||
return
|
||||
declared = {
|
||||
call.get("id")
|
||||
for call in (messages[last_idx].get("tool_calls") or [])
|
||||
if call.get("id")
|
||||
}
|
||||
answered = {
|
||||
messages[j].get("tool_call_id")
|
||||
for j in range(last_idx + 1, len(messages))
|
||||
if messages[j].get("role") == "tool"
|
||||
}
|
||||
if not declared or not declared.issubset(answered):
|
||||
del messages[last_idx:]
|
||||
|
||||
LOGIN_REQUEST = (
|
||||
"Welcome to Devii. Ask me anything - I can generate clients and bots, search the docs, "
|
||||
"fetch pages, and more. Sign in to DevPlace whenever you want me to work on your own account."
|
||||
@@ -72,7 +98,9 @@ class DeviiSession:
|
||||
settings.base_url, settings.timeout_seconds, settings.platform_api_key
|
||||
)
|
||||
self.avatar = AvatarController()
|
||||
self.browser = ClientController(allow_eval=settings.allow_eval)
|
||||
self.browser = ClientController(
|
||||
allow_eval=settings.allow_eval, allow_control=settings.browser_control
|
||||
)
|
||||
self.store = task_store
|
||||
self.task_controller = TaskController(self.store)
|
||||
self.agentic = AgenticController(lessons, settings)
|
||||
@@ -144,6 +172,7 @@ class DeviiSession:
|
||||
self._turn_tool_calls = 0
|
||||
self._pending_session: str | None = None
|
||||
self._turns: set[asyncio.Task] = set()
|
||||
self._turn_epoch = 0
|
||||
|
||||
def restore_history(self, messages: list[dict[str, Any]]) -> None:
|
||||
if messages:
|
||||
@@ -288,41 +317,79 @@ class DeviiSession:
|
||||
await self._emit({"type": "clear"}, buffer=False)
|
||||
|
||||
def spawn_turn(self, text: str) -> None:
|
||||
task = asyncio.create_task(self._run_turn(text))
|
||||
epoch = self._turn_epoch
|
||||
task = asyncio.create_task(self._run_turn(text, epoch))
|
||||
self._turns.add(task)
|
||||
task.add_done_callback(self._turns.discard)
|
||||
|
||||
async def cancel_turns(self) -> None:
|
||||
self._turn_epoch += 1
|
||||
turns = [task for task in self._turns if not task.done()]
|
||||
if not turns:
|
||||
return
|
||||
for task in turns:
|
||||
task.cancel()
|
||||
await asyncio.gather(*turns, return_exceptions=True)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*turns, return_exceptions=True), timeout=10
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"cancel_turns: a turn did not unwind within 10s for %s/%s (epoch-guarded)",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
|
||||
async def reset(self) -> None:
|
||||
await self.cancel_turns()
|
||||
await self.reset_conversation()
|
||||
await self._emit(
|
||||
{"type": "status", "text": "Reset. Conversation cleared."}, buffer=False
|
||||
)
|
||||
|
||||
async def _run_turn(self, text: str) -> None:
|
||||
async def stop(self) -> None:
|
||||
await self.cancel_turns()
|
||||
async with self._lock:
|
||||
_repair_history(self.agent._messages)
|
||||
if self.persist_conversation:
|
||||
try:
|
||||
self._conv.save(
|
||||
self.owner_kind, self.owner_id, self.agent._messages
|
||||
)
|
||||
except Exception: # noqa: BLE001 - persistence must not break the socket
|
||||
logger.exception(
|
||||
"Failed to persist history after stop for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
await self._emit({"type": "status", "text": "Stopped."}, buffer=False)
|
||||
|
||||
async def _run_turn(self, text: str, epoch: int) -> None:
|
||||
turn_id = uuid_utils.uuid7().hex
|
||||
started_at = _now_iso()
|
||||
before = self._cost_snapshot()
|
||||
self._turn_tool_calls = 0
|
||||
reply = ""
|
||||
error = ""
|
||||
cancelled = False
|
||||
try:
|
||||
async with self._lock:
|
||||
self._refresh_tools()
|
||||
self._refresh_system_prompt()
|
||||
reply = await self.agent.respond(text)
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
if epoch == self._turn_epoch:
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - reported to the browser, recorded for audit
|
||||
error = str(exc)
|
||||
logger.exception("Turn failed for %s/%s", self.owner_kind, self.owner_id)
|
||||
await self._emit({"type": "error", "text": error}, buffer=True)
|
||||
if epoch == self._turn_epoch:
|
||||
await self._emit({"type": "error", "text": error}, buffer=True)
|
||||
finally:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
if not cancelled and epoch == self._turn_epoch:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
|
||||
def _refresh_tools(self) -> None:
|
||||
builtin = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
|
||||
Reference in New Issue
Block a user