# Jaka Agent v2 — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Rewrite jaka_agent.pl as a clean ~400-line orchestrator that processes member replies silently (no outbound replies to members) while preserving boss/client/staff email handling and proactive outreach.

**Architecture:** Single-file orchestrator that routes emails by sender type. Member replies get one-pass silent processing (save files, single AI call for resume extraction, update profile). Boss/staff emails use existing AI agent loop. All existing Jaka:: modules stay unchanged.

**Tech Stack:** Perl 5, DBI/MySQL, Net::POP3 (SSL), Jaka::* modules, Claude API (single-shot for resume parsing)

---

### Task 1: Back up v1 and create v2 skeleton

**Files:**
- Rename: `jaka_agent.pl` → `jaka_agent_v1.pl`
- Create: `jaka_agent.pl` (new v2)

**Step 1: Rename the old file**

```bash
cd /var/www/vhosts/hbcuconnect.com/httpdocs/crons/jaka
cp jaka_agent.pl jaka_agent_v1.pl
chown hbcuconnect:psacln jaka_agent_v1.pl
```

**Step 2: Create v2 skeleton with initialization block**

Write `jaka_agent.pl` with:
- Same shebang, strict/warnings, BEGIN block for @INC
- Same imports (DBI, JSON, Getopt::Long, Net::POP3, POSIX, Encode, Digest::MD5)
- Same 8 Jaka:: module imports
- Same CLI option parsing (--test, --verbose, --email, --draft, --start, --stop, --force, --selftest, --status, --task)
- Same config aliases ($JAKA_EMAIL, $BOSS_EMAIL, $EMAIL_SERVER, etc.)
- Same module state sync block
- Same dispatch: --start, --stop, --status, --selftest, --email → process_emails(), --task, default usage
- Same END block for lock cleanup, same signal handlers
- Empty stubs for: `process_emails()`, `silent_process_member_reply()`, `parse_resume_with_ai()`, `run_proactive_tasks()`, `log_msg()`

Copy these helpers verbatim from v1:
- `log_msg()` (v1 lines 244-253)
- `format_response_html()` — import from Jaka::Inbox (already a module function)
- `run_proactive_tasks()` — just delegates to Jaka::Outreach (v1 lines ~1500-1510)

**Step 3: Syntax check**

```bash
perl -c jaka_agent.pl
```
Expected: `jaka_agent.pl syntax OK`

**Step 4: Fix ownership**

```bash
chown hbcuconnect:psacln jaka_agent.pl
```

---

### Task 2: Implement `process_emails()` — POP3 fetch and routing

**Files:**
- Modify: `jaka_agent.pl`

**Step 1: Write the POP3 connection and fetch block**

Copy from v1 lines 274-342:
- Stop file check
- `acquire_lock()`
- Sanity checks (unless `$force_mode`)
- POP3 connect + login
- If 0 messages → run proactive tasks → release lock → return
- Fetch ALL messages up front into `%fetched_msgs` hash (critical for POP3 timeout avoidance)

**Step 2: Write the message processing loop**

For each fetched message:
1. `parse_email()` from Jaka::Inbox
2. `record_inbound_message()` dedup check (same as v1 lines 354-368, but WITHOUT the api_retry fallthrough — if not new, SKIP and delete)
3. Auto-reply/bounce detection (v1 lines 370-380)
4. Self-loop detection (v1 lines 382-390)
5. Sender classification: `$sender_is_boss`, `$sender_is_staff`, `$sender_is_external`
6. Parse forwarded email + detect instruction

**Step 3: Write the routing switch**

```perl
my ($reply_to, $reply_cc, $agent_task, $send_external);

# CASE 1: Boss forwards unsubscribe → handle directly (same as v1)
# CASE 2: Boss forwards client email → AI agent, reply to client CC boss
# CASE 2b: Boss sends documents → save + confirm (no agent)
# CASE 2c: Boss asks for report → build + send (no agent)
# CASE 2c-alt: Boss conversation → AI agent, reply to boss
# CASE 3: Staff question → AI agent, draft only
# CASE 4: External known member → silent_process_member_reply(), NO REPLY
# FALLBACK: Unknown external → forward to boss
```

For CASES 2/2b/2c/3: Copy the routing logic from v1 (setting $reply_to, $reply_cc, $agent_task, $send_external). These are straightforward assignments.

For CASE 4 (the big change):
```perl
elsif ($sender_is_external) {
    my $member_info = check_member_outreach($email->{from});

    # Also check jaka_emails + registry_data as fallback lookup
    if (!$member_info || !$member_info->{registry_id}) {
        $member_info = _fallback_member_lookup($email->{from});
    }

    if ($member_info && $member_info->{registry_id}) {
        # SILENT PROCESSING — no reply, no agent loop
        silent_process_member_reply($email, $member_info);
        update_inbound_disposition($email, 'processed', 'Silent member processing');
        $pop->delete($msgnum) unless $test_mode;
        $processed++;
        next;  # Skip the agent/reply block below
    } else {
        # Unknown sender → forward to boss
        my $fwd_body = "External email from: $email->{from}\nSubject: $email->{subject}\n\n$email->{body}";
        send_email(to => $BOSS_EMAIL, subject => "FW: $email->{subject}", body => format_response_html($fwd_body), category => 'forward') unless $test_mode;
        update_inbound_disposition($email, 'processed', 'Forwarded to boss');
        $pop->delete($msgnum) unless $test_mode;
        $processed++;
        next;
    }
}
```

**Step 4: Write the agent execution block (for CASE 2/3 only)**

Copy from v1 lines 1370-1465 but simplified:
- Thread key + max-turns check
- `run_agent($agent_task)`
- Double-send guard (check `$Jaka::Tools::last_send_email_id`)
- If `$send_external` → send via gateway
- Else → save as draft
- Update disposition to 'processed'
- Delete from POP3
- Error handling: log error, delete from POP3 anyway (NO api_retry — one-shot)

**Key difference from v1:** On agent failure, delete the email anyway and log the error. No retry mechanism. This eliminates the entire api_retry complexity.

**Step 5: Close the loop**

- Print summary: "Processed: $processed, Skipped: $skipped"
- `$pop->quit`
- `run_proactive_tasks()`
- `release_lock()`

**Step 6: Syntax check**

```bash
perl -c jaka_agent.pl
```

---

### Task 3: Implement `silent_process_member_reply()`

**Files:**
- Modify: `jaka_agent.pl`

**Step 1: Write the function**

```perl
sub silent_process_member_reply {
    my ($email, $member_info) = @_;
    my $registry_id = $member_info->{registry_id};

    print "SILENT PROCESS: Member $email->{from} (registry_id: $registry_id)\n";
    log_msg("SILENT PROCESS", "From: $email->{from}, registry_id: $registry_id");

    # 1. Classify attachments
    my (@photo_atts, @resume_atts);
    if ($email->{attachments} && @{$email->{attachments}}) {
        for my $att (@{$email->{attachments}}) {
            my $ext = lc($att->{type} || '');
            if ($ext =~ /^(jpg|jpeg|png|gif|bmp|webp|heic)$/) {
                push @photo_atts, $att;
            } elsif ($ext =~ /^(pdf|doc|docx|rtf|txt)$/) {
                push @resume_atts, $att;
            }
        }
    }

    # 2. Save photos
    for my $att (@photo_atts) {
        next unless $att->{path} && -f $att->{path};
        my $result = eval { decode_json(handle_save_member_photo({
            registry_id => $registry_id,
            image_path  => $att->{path}
        })) } || {};
        if ($result->{success}) {
            log_msg("PHOTO SAVED", "registry_id=$registry_id, file=$att->{filename}");
        } else {
            log_msg("PHOTO FAIL", "registry_id=$registry_id: " . ($result->{error} || 'unknown'));
        }
    }

    # 3. Save + parse resumes
    for my $att (@resume_atts) {
        next unless $att->{path} && -f $att->{path};
        my $save_result = eval { decode_json(handle_save_member_resume({
            registry_id => $registry_id,
            resume_path => $att->{path}
        })) } || {};

        if ($save_result->{success} && $save_result->{resume_text}) {
            # Single AI call to extract structured data
            my $extracted = parse_resume_with_ai($save_result->{resume_text});
            if ($extracted) {
                apply_extracted_profile($registry_id, $extracted);
            }
            log_msg("RESUME SAVED", "registry_id=$registry_id, file=$att->{filename}");
        } else {
            log_msg("RESUME FAIL", "registry_id=$registry_id: " . ($save_result->{error} || 'unknown'));
        }
    }

    # 4. Parse body text for profile hints (college, employer mentioned)
    my $body = $email->{body} || '';
    $body =~ s/<[^>]+>//g;
    $body =~ s/[A-Za-z0-9+\/=]{76,}\n?//g;
    $body =~ s/^\s+|\s+$//g;
    if (length($body) > 20 && length($body) < 2000 && !@resume_atts) {
        # Only parse body if no resume (resume parsing covers this already)
        parse_body_for_profile_hints($registry_id, $body);
    }

    # 5. Update outreach status
    update_outreach_status($registry_id, @photo_atts || @resume_atts ? 'completed' : 'replied');
}
```

**Step 2: Write `_fallback_member_lookup()`**

Simplified from v1 fallback (lines 1043-1100):
```perl
sub _fallback_member_lookup {
    my ($from_email) = @_;
    my $from_lc = lc($from_email);

    # Check jaka_emails for prior conversation
    my $dbh = eval { DBI->connect("DBI:mysql:database=$LOCAL_DB_NAME;host=$LOCAL_DB_HOST", "root", "") };
    if ($dbh) {
        my ($cnt, $rid) = $dbh->selectrow_array(
            "SELECT COUNT(*), MAX(related_id) FROM jaka_emails WHERE LOWER(to_email) = ? AND status IN ('sent','draft') AND created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH)",
            undef, $from_lc
        );
        $dbh->disconnect;
        return { registry_id => $rid } if $cnt && $rid;
    }

    # Check registry_data by email
    my $hdbh = eval { get_dbh('hbcu_central') };
    if ($hdbh) {
        my ($rid) = $hdbh->selectrow_array(
            "SELECT registry_id FROM registry_data WHERE LOWER(email) = ? LIMIT 1",
            undef, $from_lc
        );
        $hdbh->disconnect;
        return { registry_id => $rid } if $rid;
    }

    return undef;
}
```

**Step 3: Syntax check**

```bash
perl -c jaka_agent.pl
```

---

### Task 4: Implement `parse_resume_with_ai()` and `apply_extracted_profile()`

**Files:**
- Modify: `jaka_agent.pl`

**Step 1: Write `parse_resume_with_ai()`**

Single Claude API call — no agent loop, no tools:
```perl
sub parse_resume_with_ai {
    my ($resume_text) = @_;
    return undef unless $resume_text && length($resume_text) > 50;

    # Truncate to avoid excessive token usage
    $resume_text = substr($resume_text, 0, 8000) if length($resume_text) > 8000;

    my $prompt = qq{Extract structured data from this resume. Return ONLY valid JSON with these fields:
{
  "job_title": "current or most recent job title",
  "current_employer": "current or most recent employer",
  "college": "college/university name",
  "major": "field of study",
  "city": "current city",
  "state": "2-letter state code",
  "skills": "comma-separated skill list",
  "years_experience": "estimated years as number",
  "work_history": [
    {"title": "...", "company": "...", "from": "YYYY-MM", "to": "YYYY-MM or present", "city": "...", "state": "...", "duties": "brief summary"}
  ]
}

If a field cannot be determined, omit it. Return ONLY the JSON object, no markdown, no explanation.

RESUME TEXT:
$resume_text};

    my $ua = LWP::UserAgent->new(timeout => 30);
    my $response = $ua->post(
        'https://api.anthropic.com/v1/messages',
        'Content-Type'      => 'application/json',
        'x-api-key'         => $CFG->{ANTHROPIC_API_KEY},
        'anthropic-version' => '2023-06-01',
        Content => encode_json({
            model      => 'claude-sonnet-4-20250514',
            max_tokens => 2000,
            messages   => [{ role => 'user', content => $prompt }]
        })
    );

    unless ($response->is_success) {
        log_msg("RESUME PARSE FAIL", "API error: " . $response->status_line);
        return undef;
    }

    my $data = eval { decode_json($response->decoded_content) };
    return undef unless $data && $data->{content} && $data->{content}[0];

    my $text = $data->{content}[0]{text} || '';
    # Extract JSON from response (handle possible markdown wrapping)
    if ($text =~ /\{[\s\S]*\}/s) {
        my ($json_str) = $text =~ /(\{[\s\S]*\})/s;
        my $parsed = eval { decode_json($json_str) };
        if ($parsed) {
            log_msg("RESUME PARSED", "Extracted: job_title=" . ($parsed->{job_title} || 'N/A'));
            return $parsed;
        }
    }

    log_msg("RESUME PARSE FAIL", "Could not parse JSON from API response");
    return undef;
}
```

**Step 2: Write `apply_extracted_profile()`**

Calls existing Tools.pm handlers directly:
```perl
sub apply_extracted_profile {
    my ($registry_id, $data) = @_;

    # Update basic profile fields
    my %profile_update = (registry_id => $registry_id);
    for my $field (qw(college major city state)) {
        $profile_update{$field} = $data->{$field} if $data->{$field};
    }
    # Map 'college' to 'hbcu' field name expected by handler
    $profile_update{hbcu} = delete $profile_update{college} if $profile_update{college};

    if (keys(%profile_update) > 1) {
        eval { handle_update_member_profile(\%profile_update) };
        log_msg("PROFILE UPDATE", "registry_id=$registry_id fields=" . join(',', grep { $_ ne 'registry_id' } keys %profile_update));
    }

    # Update professional data
    my %prof_update = (registry_id => $registry_id);
    for my $field (qw(job_title current_employer years_experience skills)) {
        $prof_update{$field} = $data->{$field} if $data->{$field};
    }
    if (keys(%prof_update) > 1) {
        eval { handle_update_professional_data(\%prof_update) };
    }

    # Add work history entries
    if ($data->{work_history} && ref($data->{work_history}) eq 'ARRAY') {
        for my $job (@{$data->{work_history}}) {
            next unless $job->{title} && $job->{company};
            eval { handle_add_work_history({
                registry_id  => $registry_id,
                title        => $job->{title},
                company_name => $job->{company},
                city         => $job->{city} || '',
                state        => $job->{state} || '',
                worked_from  => $job->{from} || '',
                worked_to    => $job->{to} || '',
                duties       => $job->{duties} || '',
            }) };
        }
    }
}
```

**Step 3: Write `parse_body_for_profile_hints()`**

Simple regex extraction — no AI:
```perl
sub parse_body_for_profile_hints {
    my ($registry_id, $body) = @_;
    # Look for obvious profile data in the email body text
    # This catches members who type "I went to Howard University" or "I work at Google"
    # Keep it simple — only extract high-confidence patterns

    my %updates;

    # College mentions (common HBCU names)
    if ($body =~ /(?:attend(?:ed)?|went to|graduated? from|student at|alumni? of)\s+([A-Z][A-Za-z\s&]+(?:University|College|Institute|State))/i) {
        $updates{hbcu} = $1;
    }

    # Employer mentions
    if ($body =~ /(?:work(?:ing)? (?:at|for)|employed (?:at|by)|(?:I'm|I am) (?:at|with))\s+([A-Z][A-Za-z\s&,.]+?)(?:\.|,|\s+as\s|\s+in\s|$)/i) {
        my $emp = $1;
        $emp =~ s/\s+$//;
        $updates{current_employer} = $emp if length($emp) > 2 && length($emp) < 100;
    }

    if (%updates) {
        if ($updates{hbcu}) {
            eval { handle_update_member_profile({ registry_id => $registry_id, hbcu => $updates{hbcu} }) };
        }
        if ($updates{current_employer}) {
            eval { handle_update_professional_data({ registry_id => $registry_id, current_employer => $updates{current_employer} }) };
        }
        log_msg("BODY HINTS", "registry_id=$registry_id extracted=" . join(',', keys %updates));
    }
}
```

**Step 4: Write `update_outreach_status()`**

```perl
sub update_outreach_status {
    my ($registry_id, $new_status) = @_;
    my $dbh = eval { DBI->connect("DBI:mysql:database=$LOCAL_DB_NAME;host=$LOCAL_DB_HOST", "root", "") };
    return unless $dbh;
    $dbh->do(
        "UPDATE jaka_member_outreach SET status = ? WHERE registry_id = ? AND status IN ('sent','replied') ORDER BY created_at DESC LIMIT 1",
        undef, $new_status, $registry_id
    );
    $dbh->disconnect;
}
```

**Step 5: Syntax check**

```bash
perl -c jaka_agent.pl
```

---

### Task 5: Write and run tests

**Files:**
- Modify: `tests/run_tests.pl` (update for v2 routing)

**Step 1: Update test harness for v2**

Key test changes:
- **Test 3 (member reply):** Should produce ZERO outbound emails (silent processing). Verify profile was updated instead.
- **Test 4 (dedup):** Keep — still relevant.
- **Test 5 (thread limit):** Only applies to boss/client threads now.
- **Remove:** Any test that expects a reply to a member.
- **Add:** Test for silent_process_member_reply with photo attachment → verify handle_save_member_photo was called.
- **Add:** Test for unknown external → verify forward to boss.

**Step 2: Run tests**

```bash
cd /var/www/vhosts/hbcuconnect.com/httpdocs/crons/jaka
perl tests/run_tests.pl
```

Expected: All tests pass, 0 failed.

---

### Task 6: Manual integration test and enable cron

**Step 1: Test with real inbox (test mode)**

```bash
cd /var/www/vhosts/hbcuconnect.com/httpdocs/crons/jaka
perl jaka_agent.pl --email --verbose --test
```

Verify:
- POP3 connects and fetches messages
- Member replies are processed silently (no outbound emails)
- Boss-forwarded emails trigger agent + reply
- No errors in output

**Step 2: Test one live run**

```bash
perl jaka_agent.pl --email --verbose
```

Check:
- Members got NO reply emails
- Profile data was updated
- Boss/client emails worked normally

**Step 3: Re-enable cron**

```bash
# Edit crontab — uncomment the jaka line
crontab -e
# Change:
#   #*/5 * * * * cd ... && perl jaka_agent.pl --email ...
# To:
#   */5 * * * * cd ... && perl jaka_agent.pl --email ...
```

**Step 4: Remove stop file**

```bash
rm -f /tmp/jaka_agent.stop
```

**Step 5: Monitor first few runs**

```bash
tail -f /var/log/jaka_agent.log
```

Verify no CIRCUIT BREAKER trips, no duplicate sends, reasonable API call counts.
