careervivid 1.12.5 → 1.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careervivid",
3
- "version": "1.12.5",
3
+ "version": "1.12.6",
4
4
  "description": "Official CLI for CareerVivid — publish articles, diagrams, and portfolio updates from your terminal or AI agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "main": "./dist/index.js",
11
11
  "files": [
12
12
  "dist",
13
+ "src/apply/browser_sidecar.py",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ browser_sidecar.py — browser-use agent sidecar for cv jobs apply
4
+
5
+ This script is called by the TypeScript harness (apply.ts) as a subprocess.
6
+ It receives a task + context via stdin as JSON, executes the task using
7
+ browser-use + Gemini, and streams progress + final result to stdout as JSON lines.
8
+
9
+ Usage (called by TypeScript, not directly):
10
+ python3 browser_sidecar.py < input.json
11
+
12
+ Input JSON (stdin):
13
+ {
14
+ "url": "https://jobs.ashbyhq.com/...",
15
+ "api_key": "AIza...",
16
+ "model": "gemini-3-flash-preview",
17
+ "resume_pdf_path": "/Users/.../.careervivid/resume.pdf",
18
+ "profile": {
19
+ "firstName": "Jiawen", "lastName": "Zhu",
20
+ "email": "zhujiawen519@gmail.com",
21
+ "phone": "(408) 599-4164",
22
+ "linkedin": "https://www.linkedin.com/in/jiawenzhu/",
23
+ "github": "https://github.com/JiawenZhu",
24
+ "portfolio": "https://jiawenzhu.github.io/profile/"
25
+ },
26
+ "profile_dir": "/Users/.../.careervivid/browser-session"
27
+ }
28
+
29
+ Output (stdout, JSON lines):
30
+ {"type": "step", "message": "Navigating to application page..."}
31
+ {"type": "done", "result": "Successfully filled form. Browser is open for review."}
32
+ {"type": "error", "message": "Could not find submit button."}
33
+ """
34
+
35
+ import asyncio
36
+ import json
37
+ import os
38
+ import sys
39
+ import aiohttp
40
+ import requests
41
+ from pathlib import Path
42
+
43
+
44
+ def emit(msg_type: str, message: str) -> None:
45
+ """Write a JSON line to stdout for the TypeScript parent to read."""
46
+ print(json.dumps({"type": msg_type, "message": message}), flush=True)
47
+
48
+
49
+ def build_task(url: str, profile: dict, resume_pdf_path: str) -> str:
50
+ """Build the full task prompt with user info embedded."""
51
+ user_info_lines = []
52
+ field_map = {
53
+ "firstName": "First Name",
54
+ "lastName": "Last Name",
55
+ "email": "Email",
56
+ "phone": "Phone",
57
+ "linkedin": "LinkedIn",
58
+ "github": "GitHub",
59
+ "portfolio": "Website/Portfolio",
60
+ "city": "City",
61
+ "state": "State",
62
+ "country": "Country",
63
+ "currentTitle": "Current Title",
64
+ "currentCompany": "Current Company",
65
+ "yearsOfExperience": "Years of Experience",
66
+ "workAuthorization": "Work Authorization",
67
+ }
68
+ for key, label in field_map.items():
69
+ val = profile.get(key, "")
70
+ if val:
71
+ user_info_lines.append(f"{label}: {val}")
72
+
73
+ user_info = "\n".join(user_info_lines) if user_info_lines else "(no profile data provided)"
74
+ has_pdf = resume_pdf_path and Path(resume_pdf_path).exists()
75
+
76
+ return f"""
77
+ 1. Navigate to {url}
78
+ 2. Wait for the application form to fully load before taking any action.
79
+ 3. The ATS platform may attempt to auto-fill fields when you upload a resume, and it frequently
80
+ gets emails and links WRONG. You must explicitly verify all visible text input fields.
81
+ If they are incorrect or empty, CLEAR the field completely and type precisely this data:
82
+
83
+ {user_info}
84
+
85
+ 4. IMPORTANT: Email and LinkedIn URLs must be exactly correct — these are most commonly
86
+ corrupted by ATS auto-fill. Clear and retype them precisely.
87
+ {"5. Upload the resume document from this exact path: " + resume_pdf_path if has_pdf else "5. (No resume PDF provided — skip file upload)"}
88
+ 6. If there are standard screening questions (Visa status, voluntary EEO disclosures,
89
+ work authorization), answer them logically based on standard software engineering norms.
90
+ 7. IMPORTANT: DO NOT click "Submit Application", "Submit", or any final submission button.
91
+ Once you have populated all visible fields, use the `done` action. The user will review
92
+ and submit manually.
93
+ """.strip()
94
+
95
+
96
+ class ChatCareerVivid:
97
+ """
98
+ A lightweight LangChain-compatible wrapper for the CareerVivid LLM Proxy.
99
+ Allows use of account credits instead of local API keys.
100
+ """
101
+ def __init__(self, model: str, cv_api_key: str):
102
+ self.model = model
103
+ self.model_name = model # browser-use cloud_events.py reads .model_name
104
+ self.cv_api_key = cv_api_key
105
+ self.proxy_url = "https://us-west1-jastalk-firebase.cloudfunctions.net/agentProxy"
106
+ self.provider = "google"
107
+
108
+ def _convert_messages(self, messages):
109
+ """Convert LangChain messages to CareerVivid/Gemini Content format."""
110
+ contents = []
111
+ for msg in messages:
112
+ role = "user" if getattr(msg, "type", "human") in ["human", "system"] else "model"
113
+ # Handle both string content and list content (multimodal)
114
+ text = msg.content if isinstance(msg.content, str) else str(msg.content)
115
+ contents.append({"role": role, "parts": [{"text": text}]})
116
+ return contents
117
+
118
+ def invoke(self, messages, *args, **kwargs):
119
+ """Synchronous call to the proxy.
120
+ *args absorbs extra positional args (e.g. config) that browser-use may pass.
121
+ """
122
+ payload = {
123
+ "apiKey": self.cv_api_key,
124
+ "model": self.model,
125
+ "contents": self._convert_messages(messages)
126
+ }
127
+ resp = requests.post(self.proxy_url, json=payload, timeout=60)
128
+ resp.raise_for_status()
129
+ data = resp.json()
130
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
131
+ from langchain_core.messages import AIMessage
132
+ return AIMessage(content=text)
133
+
134
+ async def ainvoke(self, messages, *args, **kwargs):
135
+ """Asynchronous call to the proxy.
136
+ *args absorbs extra positional args (e.g. config) that browser-use may pass.
137
+ """
138
+ payload = {
139
+ "apiKey": self.cv_api_key,
140
+ "model": self.model,
141
+ "contents": self._convert_messages(messages)
142
+ }
143
+ async with aiohttp.ClientSession() as session:
144
+ async with session.post(self.proxy_url, json=payload) as resp:
145
+ resp.raise_for_status()
146
+ data = await resp.json()
147
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
148
+ from langchain_core.messages import AIMessage
149
+ return AIMessage(content=text)
150
+
151
+
152
+ def get_llm(llm_config: dict):
153
+ """Factory to initialize the correct LangChain LLM based on provider."""
154
+ provider = llm_config.get("provider", "gemini")
155
+ model = llm_config.get("model", "")
156
+ api_key = llm_config.get("apiKey", "")
157
+ base_url = llm_config.get("baseUrl", "")
158
+
159
+ if provider == "careervivid":
160
+ return ChatCareerVivid(model=model, cv_api_key=api_key)
161
+
162
+ if provider == "openai":
163
+ from browser_use.llm.openai import ChatOpenAI
164
+ return ChatOpenAI(model=model, api_key=api_key, base_url=base_url)
165
+
166
+ if provider == "anthropic":
167
+ from browser_use.llm.anthropic import ChatAnthropic
168
+ return ChatAnthropic(model=model, api_key=api_key)
169
+
170
+ if provider == "gemini" or provider == "google":
171
+ from browser_use.llm.google import ChatGoogle
172
+ return ChatGoogle(model=model, api_key=api_key)
173
+
174
+ if provider == "openrouter":
175
+ from browser_use.llm.openrouter import ChatOpenRouter
176
+ return ChatOpenRouter(model=model, api_key=api_key)
177
+
178
+ raise ValueError(f"Unsupported LLM provider: {provider}")
179
+
180
+
181
+ async def run_agent(
182
+ url: str,
183
+ llm_config: dict,
184
+ resume_pdf_path: str,
185
+ profile: dict,
186
+ profile_dir: str,
187
+ task_override: str | None = None,
188
+ ) -> str:
189
+ """Run browser-use agent with the given task and selected LLM."""
190
+ from browser_use import Agent, Browser
191
+ from browser_use.browser.profile import BrowserProfile
192
+
193
+ emit("step", f"Initializing LLM: {llm_config.get('model')} via {llm_config.get('provider')}")
194
+ llm = get_llm(llm_config)
195
+
196
+ emit("step", "Setting up browser profile...")
197
+ Path(profile_dir).mkdir(parents=True, exist_ok=True)
198
+
199
+ chrome_paths = [
200
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
201
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
202
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
203
+ ]
204
+ chrome_binary = next((p for p in chrome_paths if Path(p).exists()), None)
205
+
206
+ browser_profile = BrowserProfile(
207
+ user_data_dir=profile_dir,
208
+ executable_path=chrome_binary,
209
+ headless=False,
210
+ args=[
211
+ "--no-sandbox",
212
+ "--disable-blink-features=AutomationControlled",
213
+ "--disable-infobars",
214
+ "--start-maximized",
215
+ "--no-first-run",
216
+ "--no-default-browser-check",
217
+ ],
218
+ ignore_https_errors=True,
219
+ )
220
+
221
+ browser = Browser(browser_profile=browser_profile)
222
+ task = task_override if task_override else build_task(url, profile, resume_pdf_path)
223
+ emit("step", f"Starting agent — filling form at: {url if url != 'about:blank' else '(URL embedded in task)'}")
224
+
225
+ available_paths = []
226
+ if resume_pdf_path and Path(resume_pdf_path).exists():
227
+ available_paths = [resume_pdf_path]
228
+ emit("step", f"Resume PDF ready: {resume_pdf_path}")
229
+ else:
230
+ emit("step", "No resume PDF found — skipping file upload")
231
+
232
+ async def on_step(step_info):
233
+ try:
234
+ thought = getattr(step_info, "model_output", None)
235
+ if thought and hasattr(thought, "current_state"):
236
+ state = thought.current_state
237
+ next_goal = getattr(state, "next_goal", "") or ""
238
+ if next_goal:
239
+ emit("step", f"🤖 {next_goal}")
240
+ except Exception:
241
+ pass
242
+
243
+ agent = Agent(
244
+ task=task,
245
+ llm=llm,
246
+ browser=browser,
247
+ available_file_paths=available_paths if available_paths else None,
248
+ max_actions_per_step=5,
249
+ )
250
+
251
+ try:
252
+ result = await agent.run(max_steps=30, on_step_start=on_step)
253
+ final = str(result.final_result() if hasattr(result, "final_result") else result)
254
+ return final
255
+ except Exception as e:
256
+ if browser:
257
+ await browser.close()
258
+ raise
259
+
260
+
261
+ def main() -> None:
262
+ """Entry point: read JSON from stdin, run agent, emit results."""
263
+ try:
264
+ raw = sys.stdin.read().strip()
265
+ if not raw:
266
+ raise ValueError("No input received on stdin")
267
+ payload = json.loads(raw)
268
+ except Exception as e:
269
+ emit("error", f"Failed to parse input: {e}")
270
+ sys.exit(1)
271
+
272
+ url = payload.get("url", "")
273
+ llm_config = payload.get("llm_config", {})
274
+ resume_pdf = payload.get("resume_pdf_path", "")
275
+ profile = payload.get("profile", {})
276
+ profile_dir = payload.get("profile_dir") or os.path.expanduser("~/.careervivid/browser-session")
277
+ task_override = payload.get("task_override", "") # from cv agent --jobs tools
278
+
279
+ # task_override mode: the calling tool provides a full task string
280
+ # In this mode url may be empty — the task already includes the URL.
281
+ if not url and not task_override:
282
+ emit("error", "No job URL provided")
283
+ sys.exit(1)
284
+
285
+ if not llm_config:
286
+ emit("error", "No LLM configuration provided. Run: cv agent config")
287
+ sys.exit(1)
288
+
289
+ try:
290
+ result = asyncio.run(run_agent(
291
+ url or "about:blank", # task_override mode: url is embedded in task
292
+ llm_config,
293
+ resume_pdf,
294
+ profile,
295
+ profile_dir,
296
+ task_override=task_override or None,
297
+ ))
298
+ emit("done", result)
299
+ except KeyboardInterrupt:
300
+ emit("error", "Agent interrupted by user")
301
+ sys.exit(1)
302
+ except Exception as e:
303
+ emit("error", str(e))
304
+ sys.exit(1)
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()