osborn 0.8.16 → 0.8.18

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.
@@ -0,0 +1,114 @@
1
+ # Skill: Browser Apply — Step-by-Step Workday Application
2
+
3
+ Automate Workday job applications interactively, one step at a time. Each step takes a screenshot, confirms what's on screen, fills the current page, and waits before proceeding.
4
+
5
+ **This skill uses the Playwright MCP tools** (`mcp__playwright__browser_*`) for direct browser control — no scripts needed.
6
+
7
+ ## When to Use
8
+ - Any Workday ATS application (`*.wd1.myworkdayjobs.com`)
9
+ - Any multi-step JS-heavy job application form
10
+ - When you want visible, confirmable progress at each step
11
+
12
+ ## Key Principle: Step-by-Step, Not One Big Script
13
+
14
+ Do NOT write a monolithic automation script. Instead:
15
+ 1. Navigate to the URL
16
+ 2. Take a screenshot — confirm what's on screen
17
+ 3. Fill only the current step's fields
18
+ 4. Take another screenshot — confirm fields filled correctly
19
+ 5. Ask the user "Ready for next step?" before clicking Next
20
+ 6. Click Next, wait for page load, screenshot again
21
+ 7. Repeat for each step
22
+
23
+ This approach catches rendering issues, unexpected fields, and errors before they cascade.
24
+
25
+ ## Step-by-Step Execution Pattern
26
+
27
+ ### Step 0 — Open the browser
28
+ Use: `mcp__playwright__browser_navigate` with the applyManually URL
29
+
30
+ Then: `mcp__playwright__browser_take_screenshot` — show the user what loaded
31
+
32
+ ### Step 1 — Create Account / Sign In
33
+ Take a snapshot with `mcp__playwright__browser_snapshot` to see element refs.
34
+ Fill fields using `mcp__playwright__browser_fill_form` or individual `mcp__playwright__browser_type` calls.
35
+ Screenshot to confirm. Then ask user before clicking Create Account / Sign In.
36
+
37
+ ### Step 2 — Start Application
38
+ If "Start Your Application" screen appears with Apply Manually button:
39
+ Screenshot it. Click "Apply Manually" using `mcp__playwright__browser_click`.
40
+ Screenshot after.
41
+
42
+ ### Step 3 — My Information
43
+ Snapshot → fill each field → screenshot → ask user before clicking Next.
44
+
45
+ Fields to fill:
46
+ - First Name, Last Name, Phone
47
+ - Address, City, State (dropdown), Zip
48
+ - Work authorization: Yes
49
+ - Sponsorship: No
50
+
51
+ ### Step 4 — My Experience
52
+ Snapshot → click Add for each job entry → fill title/company/dates/description → save each → screenshot.
53
+ Then add education entries.
54
+ Ask user before clicking Next.
55
+
56
+ ### Step 5 — Application Questions
57
+ Snapshot to see all questions. Fill each one. **Always confirm salary expectation with user before filling** — never guess. Screenshot. Ask before Next.
58
+
59
+ ### Step 6 — Voluntary Disclosures
60
+ Select "I do not wish to answer" / "Prefer not to disclose" for all. Screenshot. Ask before Next.
61
+
62
+ ### Step 7 — Self Identify
63
+ Fill name and date. Select disability option. Screenshot. Ask before Next.
64
+
65
+ ### Step 8 — Review
66
+ Screenshot the full review page. Confirm with user before clicking Submit.
67
+
68
+ ### Step 9 — Confirm submission
69
+ Screenshot the confirmation dialog. Save it.
70
+
71
+ ## Candidate Data (Osborn Ojure)
72
+
73
+ - Email: osbornojure@gmail.com
74
+ - Password: Workday2026!
75
+ - First: Osborn, Last: Ojure
76
+ - Phone: 3127185561
77
+ - Address: 1234 N Michigan Ave, Chicago, IL 60601
78
+
79
+ Jobs:
80
+ 1. Meta API Consultant at Prehype / Audos — April 2024 to Present
81
+ 2. Full Stack Developer, Freelance — January 2016 to Present
82
+
83
+ Education:
84
+ 1. A.S. Information Systems
85
+ 2. B.S. Psychology
86
+
87
+ ## Workday data-automation-id Selector Reference
88
+
89
+ | Field | Selector |
90
+ |---|---|
91
+ | Email | `input[type="email"]` |
92
+ | Password | `input[type="password"]` |
93
+ | First name | `[data-automation-id="legalNameSection_firstName"]` |
94
+ | Last name | `[data-automation-id="legalNameSection_lastName"]` |
95
+ | Phone | `[data-automation-id="phone-number"]` |
96
+ | Address | `[data-automation-id="addressSection_addressLine1"]` |
97
+ | City | `[data-automation-id="addressSection_city"]` |
98
+ | Zip | `[data-automation-id="addressSection_postalCode"]` |
99
+ | Job title | `[data-automation-id="jobTitle"]` |
100
+ | Company | `[data-automation-id="company"]` |
101
+ | Description | `[data-automation-id="description"]` |
102
+ | Next button | `[data-automation-id="bottom-navigation-next-btn"]` |
103
+ | Create Account | `[data-automation-id="click_filter"][aria-label="Create Account"]` |
104
+
105
+ ## Critical Rules
106
+ - headless: false always (Workday renders blank in headless)
107
+ - Confirm salary with user before every submission — never auto-fill
108
+ - After "You already applied to this job" error — that confirms a previous submission worked
109
+ - Use `{ force: true }` on Workday buttons — overlay click filters block normal clicks
110
+ - Always wait for networkidle or waitForSelector after navigation before interacting
111
+
112
+ ## Playwright Install Location
113
+ Run scripts from: `/Users/newupgrade/Desktop/Developer/osborn/frontend`
114
+ (playwright is in `node_modules` there)
package/.env.example CHANGED
@@ -19,3 +19,10 @@ ANTHROPIC_API_KEY=sk-ant-...
19
19
  # Smithery (cloud-hosted MCP servers - YouTube, GitHub, etc.)
20
20
  # Get your key at: https://smithery.ai
21
21
  # SMITHERY_API_KEY=your-smithery-api-key
22
+
23
+ # Frontend URL — used by the agent to upload workspace artifacts (resumes,
24
+ # reports, PDFs, search indexes) to Supabase Storage via the frontend's
25
+ # /api/upload route. Without this, the agent falls back to inlining file
26
+ # content through the LiveKit data channel (capped at 30KB to avoid
27
+ # corrupting the publisher connection). Local dev: http://localhost:3000
28
+ # OSBORN_FRONTEND_URL=http://localhost:3000
@@ -17,6 +17,13 @@ import { fileURLToPath } from 'node:url';
17
17
  // Directory of this module — used to locate co-located prompt files (e.g., turn-shape reminder).
18
18
  const __claudeLlmDir = dirname(fileURLToPath(import.meta.url));
19
19
  const TURN_SHAPE_REMINDER_PATH = join(__claudeLlmDir, 'prompts', 'turn-shape-reminder.md');
20
+ // ≤3 direct tool call budget per turn. Reset on every UserPromptSubmit (new user message).
21
+ // Enforced mechanically in PreToolUse — the model CANNOT exceed this regardless of JSONL history.
22
+ // Task/Agent delegations are exempt (delegation is what we WANT). Sub-agent tool calls
23
+ // (agent_type !== null) are exempt (they're inside a delegation). Only the main orchestrator
24
+ // agent's direct tool calls count against the budget.
25
+ let turnToolCallCount = 0;
26
+ const TOOL_CALL_BUDGET = 3;
20
27
  /**
21
28
  * Strip markdown formatting for TTS (text-to-speech)
22
29
  * Removes **bold**, ##headers, ```code```, etc. so TTS doesn't read them literally
@@ -750,8 +757,8 @@ class ClaudeLLMStream extends llm.LLMStream {
750
757
  cwd: this.#opts.workingDirectory,
751
758
  permissionMode: this.#opts.permissionMode,
752
759
  allowedTools,
753
- model: this.#opts.model || 'haiku', // haiku for speed with limited tools, sonnet for full research capabilities (including tool use trace in response)
754
- // model: this.#opts.model || 'claude-sonnet-4-6', // Sonnet orchestrator with named sub-agents (Haiku tested but ignored delegation rules)
760
+ // model: this.#opts.model || 'haiku', // haiku for speed with limited tools, sonnet for full research capabilities (including tool use trace in response)
761
+ model: this.#opts.model || 'claude-sonnet-4-6', // Sonnet orchestrator with named sub-agents (Haiku tested but ignored delegation rules)
755
762
  enableFileCheckpointing: true,
756
763
  extraArgs: { 'replay-user-messages': null },
757
764
  ...(this.#abortController && { abortController: this.#abortController }),
@@ -824,6 +831,22 @@ class ClaudeLLMStream extends llm.LLMStream {
824
831
  const toolInput = input?.tool_input || {};
825
832
  const agentType = input?.agent_type || null;
826
833
  console.log(`🔍 PreToolUse: toolName=${toolName} agent_type=${agentType} agent_id=${input?.agent_id || 'none'} all_keys=[${Object.keys(input || {}).join(', ')}]`);
834
+ // ≤3 direct tool call budget enforcement.
835
+ // Only counts calls from the MAIN orchestrator agent (agent_type === null).
836
+ // Task/Agent delegations are exempt — delegation is the desired behavior.
837
+ // Sub-agent tool calls are exempt — they're inside a delegation.
838
+ if (!agentType && toolName !== 'Task' && toolName !== 'Agent') {
839
+ turnToolCallCount++;
840
+ if (turnToolCallCount > TOOL_CALL_BUDGET) {
841
+ console.log(`🛑 Tool budget exceeded (${turnToolCallCount}/${TOOL_CALL_BUDGET}) — DENYING ${toolName}. Must delegate via Task.`);
842
+ this.#eventEmitter.emit('tool_blocked', { name: toolName, reason: `Tool call budget exceeded (${turnToolCallCount}/${TOOL_CALL_BUDGET}). Delegate via Task.` });
843
+ return {
844
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' },
845
+ reason: `Hard limit: maximum ${TOOL_CALL_BUDGET} direct tool calls per turn (you are at ${turnToolCallCount}). Delegate the remaining work to a sub-agent via Task(subagent_type=\'researcher\'|\'writer\'|\'reasoner\', run_in_background: true). This is a system-enforced limit.`,
846
+ };
847
+ }
848
+ console.log(`🔧 Tool call ${turnToolCallCount}/${TOOL_CALL_BUDGET}: ${toolName}`);
849
+ }
827
850
  // Write/Edit/MultiEdit access control
828
851
  if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
829
852
  // Writer sub-agent gets full write access everywhere
@@ -871,9 +894,11 @@ class ClaudeLLMStream extends llm.LLMStream {
871
894
  matcher: '.*',
872
895
  hooks: [async (input) => {
873
896
  try {
897
+ // Reset the per-turn tool call counter so the new turn starts fresh.
898
+ turnToolCallCount = 0;
874
899
  const reminder = readFileSync(TURN_SHAPE_REMINDER_PATH, 'utf-8');
875
900
  const promptPreview = String(input?.prompt || '').substring(0, 60).replace(/\n/g, ' ');
876
- console.log(`📌 UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars) for prompt="${promptPreview}..."`);
901
+ console.log(`📌 UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars) for prompt="${promptPreview}..." [tool budget reset to 0/${TOOL_CALL_BUDGET}]`);
877
902
  return {
878
903
  hookSpecificOutput: {
879
904
  hookEventName: 'UserPromptSubmit',
package/dist/index.js CHANGED
@@ -491,6 +491,11 @@ async function main() {
491
491
  let userState = 'listening'; // Track user speech state for queue safety
492
492
  let currentVoiceMode = voiceMode; // Track active voice mode for data handlers
493
493
  let currentProvider = realtimeConfig.provider; // Track active realtime provider
494
+ // Authenticated Supabase userId from participant metadata. Used to scope
495
+ // workspace artifact uploads to the owner's prefix in Supabase Storage.
496
+ // Empty string = anonymous / unauthenticated; uploads fall back to a
497
+ // session-only path (no user prefix).
498
+ let currentUserId = '';
494
499
  // Track the active resume session ID across scopes (ParticipantConnected + DataReceived)
495
500
  // Updated by resume_session, session_selected, continue_session, switch_session handlers
496
501
  let currentResumeSessionId;
@@ -1776,6 +1781,15 @@ async function main() {
1776
1781
  try {
1777
1782
  const metadata = JSON.parse(participant.metadata || '{}');
1778
1783
  console.log(`📋 Participant metadata:`, metadata);
1784
+ // userId from authenticated Supabase session — used to scope Supabase
1785
+ // Storage uploads so each user's workspace artifacts live under their
1786
+ // own prefix. Falls through to '' (anonymous) if not authenticated.
1787
+ if (typeof metadata.userId === 'string' && metadata.userId.length > 0) {
1788
+ currentUserId = metadata.userId;
1789
+ }
1790
+ else {
1791
+ currentUserId = '';
1792
+ }
1779
1793
  if (metadata.voiceArch === 'realtime' || metadata.voiceArch === 'direct' || metadata.voiceArch === 'pipeline') {
1780
1794
  sessionVoiceMode = metadata.voiceArch;
1781
1795
  console.log(`🎙️ Using voice mode from frontend: ${sessionVoiceMode}`);
@@ -2703,40 +2717,99 @@ async function main() {
2703
2717
  if (filePath && (filePath.includes('/osb/') || filePath.includes('.osborn/sessions/') || filePath.includes('.osborn/research/'))) {
2704
2718
  try {
2705
2719
  const fs = await import('fs');
2720
+ const path = await import('path');
2706
2721
  const fileName = filePath.split('/').pop() || '';
2707
2722
  const ext = fileName.split('.').pop()?.toLowerCase() || '';
2708
2723
  const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext);
2709
- // WebRTC SCTP data channel max message size is ~256KB. Sending
2710
- // larger payloads corrupts the publisher transport, killing ALL
2711
- // subsequent sends (publishData, streamBytes, publishTranscription)
2712
- // with "could not establish publisher connection: timeout". This
2713
- // was the root cause of the career-ops session bug: a 480KB
2714
- // evaluation report blew through the limit on resume.
2715
- // ⚠️ MUST be low — not just per-message but cumulative back-to-back pressure.
2716
- // 12 artifact requests arrive simultaneously during session resume. Even if
2717
- // each is individually "safe", flooding them kills the publisher PC. At 200KB
2718
- // the search-index.txt (136KB) passed through and poisoned the connection.
2719
- // 30KB catches search-index.txt (136KB), resume.pdf (233KB), and search-index-
2720
- // meta.json (5.7KB passes). resume.html (14KB) also passes acceptable.
2721
- const MAX_DATA_CHANNEL_BYTES = 30_000; // 30KB max per artifact
2722
- if (isImage) {
2724
+ const mimeByExt = {
2725
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
2726
+ gif: 'image/gif', webp: 'image/webp', pdf: 'application/pdf',
2727
+ html: 'text/html', md: 'text/markdown', txt: 'text/plain',
2728
+ json: 'application/json',
2729
+ };
2730
+ const mimeType = mimeByExt[ext] || 'application/octet-stream';
2731
+ // Strategy: upload the file to Supabase Storage via the frontend's
2732
+ // /api/upload route and send back just the URL. This mirrors the
2733
+ // existing frontend→agent attachment flow (where the browser uploads
2734
+ // user attachments to Supabase and passes URLs to the agent). For
2735
+ // the reverse direction we do the same: URLs are ~100 bytes, so
2736
+ // the LiveKit data channel stays healthy regardless of file size.
2737
+ //
2738
+ // Fallback to inline send if OSBORN_FRONTEND_URL isn't configured
2739
+ // OR the upload fails — with a small size cap so we don't kill the
2740
+ // publisher PC with a 480KB payload (see earlier career-ops bug).
2741
+ const FRONTEND_URL = process.env.OSBORN_FRONTEND_URL || process.env.NEXT_PUBLIC_FRONTEND_URL || '';
2742
+ const MAX_INLINE_BYTES = 30_000; // fallback-only cap
2743
+ let uploadedUrl = null;
2744
+ if (FRONTEND_URL) {
2745
+ try {
2746
+ const buf = fs.readFileSync(filePath);
2747
+ const form = new FormData();
2748
+ form.append('file', new Blob([buf], { type: mimeType }), fileName);
2749
+ form.append('folder', 'artifacts');
2750
+ // Pass userId + sessionId so /api/upload can place the file
2751
+ // under `{userId}/{sessionId}/...` in Supabase Storage for
2752
+ // easy ownership queries and future RLS policies. Both are
2753
+ // optional — route falls back to `artifacts/...` if missing.
2754
+ if (currentUserId)
2755
+ form.append('userId', currentUserId);
2756
+ // Prefer the live resume session id (updated by session
2757
+ // switches), fall back to whatever SDK session id the LLM
2758
+ // reports, fall back to empty.
2759
+ const uploadSessionId = currentResumeSessionId
2760
+ || currentLLM?.sessionId
2761
+ || '';
2762
+ if (uploadSessionId)
2763
+ form.append('sessionId', uploadSessionId);
2764
+ const r = await fetch(`${FRONTEND_URL.replace(/\/$/, '')}/api/upload`, {
2765
+ method: 'POST', body: form,
2766
+ signal: AbortSignal.timeout(15_000),
2767
+ });
2768
+ if (r.ok) {
2769
+ const j = await r.json();
2770
+ if (j.success && j.url) {
2771
+ uploadedUrl = j.url;
2772
+ console.log(`☁️ Uploaded artifact to Supabase: ${fileName} (${(buf.length / 1024).toFixed(0)}KB) → ${j.url.substring(0, 80)}...`);
2773
+ }
2774
+ else {
2775
+ console.warn(`⚠️ Upload failed for ${fileName}: ${j.error || 'unknown'}`);
2776
+ }
2777
+ }
2778
+ else {
2779
+ console.warn(`⚠️ Upload HTTP ${r.status} for ${fileName}`);
2780
+ }
2781
+ }
2782
+ catch (err) {
2783
+ console.warn(`⚠️ Upload threw for ${fileName}:`, err.message);
2784
+ }
2785
+ }
2786
+ if (uploadedUrl) {
2787
+ // Success path — send URL, no inline content.
2788
+ await sendToFrontend({
2789
+ type: 'research_artifact_content',
2790
+ filePath, fileName, url: uploadedUrl,
2791
+ isImage, mimeType,
2792
+ });
2793
+ }
2794
+ else if (isImage) {
2795
+ // Fallback: inline image (with size cap)
2723
2796
  const stats = fs.statSync(filePath);
2724
- const base64Size = Math.ceil(stats.size * 4 / 3); // base64 inflates ~33%
2725
- if (base64Size > MAX_DATA_CHANNEL_BYTES) {
2726
- console.log(`⚠️ Artifact too large for data channel: ${fileName} (${(base64Size / 1024).toFixed(0)}KB base64) — sending truncation notice`);
2797
+ const base64Size = Math.ceil(stats.size * 4 / 3);
2798
+ if (base64Size > MAX_INLINE_BYTES) {
2799
+ console.log(`⚠️ Artifact too large for inline fallback: ${fileName} (${(base64Size / 1024).toFixed(0)}KB base64) — sending truncation notice`);
2727
2800
  await sendToFrontend({ type: 'research_artifact_content', filePath, content: '', fileName, isImage: false, truncated: true, originalSize: stats.size });
2728
2801
  }
2729
2802
  else {
2730
2803
  const base64 = fs.readFileSync(filePath, 'base64');
2731
- await sendToFrontend({ type: 'research_artifact_content', filePath, content: base64, fileName, isImage: true, mimeType: `image/${ext}` });
2804
+ await sendToFrontend({ type: 'research_artifact_content', filePath, content: base64, fileName, isImage: true, mimeType });
2732
2805
  }
2733
2806
  }
2734
2807
  else {
2808
+ // Fallback: inline text (with size cap)
2735
2809
  const content = fs.readFileSync(filePath, 'utf-8');
2736
- if (Buffer.byteLength(content, 'utf-8') > MAX_DATA_CHANNEL_BYTES) {
2737
- // Send a truncated preview + metadata so the frontend knows the file exists
2738
- const truncated = content.substring(0, 5_000); // ~5KB preview (keep well under the 30KB limit)
2739
- console.log(`⚠️ Artifact too large for data channel: ${fileName} (${(Buffer.byteLength(content, 'utf-8') / 1024).toFixed(0)}KB) — sending truncated preview`);
2810
+ if (Buffer.byteLength(content, 'utf-8') > MAX_INLINE_BYTES) {
2811
+ const truncated = content.substring(0, 5_000);
2812
+ console.log(`⚠️ Artifact too large for inline fallback: ${fileName} (${(Buffer.byteLength(content, 'utf-8') / 1024).toFixed(0)}KB) sending truncated preview`);
2740
2813
  await sendToFrontend({ type: 'research_artifact_content', filePath, content: truncated, fileName, isImage: false, truncated: true, originalSize: Buffer.byteLength(content, 'utf-8') });
2741
2814
  }
2742
2815
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.8.16",
3
+ "version": "0.8.18",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {