@vruum/skills 0.6.38 → 0.6.40
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vruum",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.40",
|
|
4
4
|
"description": "Vruum AI skills + remote MCP server for B2B GTM teams. Slash commands for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis, paired with the full Vruum MCP tool surface over OAuth 2.1.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vruum AI",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vruum",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.40",
|
|
4
4
|
"description": "Vruum AI skills + remote MCP server for B2B GTM teams. Skills for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis, paired with the full Vruum MCP tool surface over OAuth 2.1.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vruum AI",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vruum/skills",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.40",
|
|
4
4
|
"description": "Vruum AI skills for Claude Code, Claude Desktop, Codex CLI, and any AI assistant with a skill directory. Slash commands for outreach triage, engagement triage, pipeline filling, prospect enrichment, and reply diagnosis. Pairs with the Vruum MCP server at https://api.vruum.ai/mcp.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"outreach",
|
|
43
43
|
"gtm"
|
|
44
44
|
],
|
|
45
|
-
"contentHash": "
|
|
45
|
+
"contentHash": "2d1d42147392e5fc34a8eb81caa215c216163d5d7ac85c15557be06d115776f4"
|
|
46
46
|
}
|
|
@@ -43,7 +43,7 @@ Externally scheduled rows are intentionally excluded from actionable items. Neve
|
|
|
43
43
|
|
|
44
44
|
Keep it short. The user knows their queue — they just need the numbers to decide what to prioritize.
|
|
45
45
|
|
|
46
|
-
**needs_draft items EXPIRE.** A nightly backend sweep (03:20 UTC) rejects any `needs_draft` row older than 14 days
|
|
46
|
+
**needs_draft items EXPIRE.** A nightly backend sweep (03:20 UTC) rejects any `needs_draft` row older than 14 days since it ENTERED needs_draft — intended garbage collection, not an operator action. Reviving via `manage_messages` action=`regenerate` grants a FRESH 14-day window (the response's `expires_at` reports the new deadline), so a revived row survives the next sweep even if the original was created months ago. An expired touch is not lost forever (the plan reschedules and mints a fresh row about a week later), but the authoring work is deferred a cycle and the queue silently shrinks. The tools tell you: the stats payload carries `needs_draft_expiring_soon_count` (rows within 3 days of the sweep — surface it in the summary when non-zero), and each needs_draft item carries `expires_at` (its sweep deadline). Consequence for triage: author nearest-deadline-first (`sort_by=expiring`), and if the queue is too big to clear in one session, clear the items closest to `expires_at` rather than sampling the freshest.
|
|
47
47
|
|
|
48
48
|
### Gmail/Vruum reconciliation lane
|
|
49
49
|
|
|
@@ -64,7 +64,7 @@ Once the user says go (or picks a focus area), pull the lightweight message queu
|
|
|
64
64
|
|
|
65
65
|
**Frame by conversation state, not sequence number.** `first_content_touch=true` means this person has NEVER received a content-bearing message — their history is only connection requests (blank or noted). That item is an OPENER no matter its T-number (a T3 email that fell back from a never-accepted LinkedIn connect is still the first thing this person reads from the sender). `first_content_touch=false` means real prior contact exists — follow-up framing is legitimate. If the field is null/absent (rows drafted before this signal shipped, or an older backend), fall back to the sequence_number heuristic: seq 1 = opener, seq ≥2 = follow-up. The full block lives on the `get_outreach_review` item as `conversation_state` (`{v, first_content_touch, prior_context: none|note_only|inbound_only|content, connection_note_text, last_delivered_channel, designed_channel, channel_rewrite_reason, channel_fallback}`) — same absent-block fallback applies when `v` is unrecognized.
|
|
66
66
|
|
|
67
|
-
**Authoring mode (needs_draft items).** The backend no longer writes outreach prose — touches arrive as `needs_draft` items carrying the decision context (channel, touch number, signals) and no content. These are not rewrites; they are blank pages. For each needs_draft item the subagent AUTHORS the message: check the person's research freshness from the review item itself — `person_researched_at` / `company_researched_at` / `research_status` are on the payload, no extra fetch needed (older than ~14 days or missing → research first with WebSearch + the research reads, and persist what you learn via `research` action=save_person, plus action=save_company when you learned something about the company, so it compounds), then write the touch from scratch in the seller's voice against the same quality standards as any review, then check it — call `check_prose` with `{item_id: <message id>, item_type: "message", content: <draft>}` and treat the `failures[]` as an advisory checklist: fix what you agree with; severity `block` failures are hard stops that bounce at post time — today: channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener) — then submit it via `manage_messages` action=edit with the content and the returned `rules_version` as `client_rules_version` — that transitions the item to a normal draft — and approve only what the user's standing instructions allow. (The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — a `block`-severity draft bounces with `prose_gate_blocked` + `failures[].fix` and a `span` naming the exact offending text.) Inbound replies also arrive as needs_draft (category inbound_reply, with the conversation attached): author the reply with full thread context. If a prospect turns out to be a bad fit at authoring time, skip the item and say why — authoring is the second qualification gate, not an obligation to write.
|
|
67
|
+
**Authoring mode (needs_draft items).** The backend no longer writes outreach prose — touches arrive as `needs_draft` items carrying the decision context (channel, touch number, signals) and no content. These are not rewrites; they are blank pages. For each needs_draft item the subagent AUTHORS the message: check the person's research freshness from the review item itself — `person_researched_at` / `company_researched_at` / `research_status` are on the payload, no extra fetch needed (older than ~14 days or missing → research first with WebSearch + the research reads, and persist what you learn via `research` action=save_person with `payload.person_id` set to the review item's person UUID (update-only — the response's `updated_fields` confirms what landed; NEVER pass the UUID as the facade `id` argument), plus action=save_company when you learned something about the company, so it compounds), then write the touch from scratch in the seller's voice against the same quality standards as any review, then check it — call `check_prose` with `{item_id: <message id>, item_type: "message", content: <draft>}` and treat the `failures[]` as an advisory checklist: fix what you agree with; severity `block` failures are hard stops that bounce at post time — today: channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener) — then submit it via `manage_messages` action=edit with the content and the returned `rules_version` as `client_rules_version` — that transitions the item to a normal draft — and approve only what the user's standing instructions allow. (The edit re-runs the same lint server-side; annotations are recorded to the label corpus, never rejected — a `block`-severity draft bounces with `prose_gate_blocked` + `failures[].fix` and a `span` naming the exact offending text.) Inbound replies also arrive as needs_draft (category inbound_reply, with the conversation attached): author the reply with full thread context. If a prospect turns out to be a bad fit at authoring time, skip the item and say why — authoring is the second qualification gate, not an obligation to write.
|
|
68
68
|
|
|
69
69
|
**Dispatch order within the authoring lane.** Pull the WARM lane directly — `search` type=messages with `warm_only=true, status=needs_draft, sort_by=expiring, fields=compact` returns exactly the LinkedIn follow-ups to accepted connections, nearest sweep deadline first, immune to page-1 truncation (warm rows are old by construction, so the default newest-first sort buries them under the cold backlog). Dispatch that lane first — they accepted the invite and the follow-up is the first real message they read. For the remaining lanes, compact rows carry `connection_status` if you need to spot stragglers, and `sort_by=expiring` works on any needs_draft pull.
|
|
70
70
|
|
|
@@ -4,7 +4,7 @@ You are reviewing and improving outreach messages before they go to a human oper
|
|
|
4
4
|
|
|
5
5
|
You do NOT approve or send messages. You review, edit if needed, and return a structured summary.
|
|
6
6
|
|
|
7
|
-
**needs_draft items are authoring jobs, not reviews.** The item arrives with decision context (channel, touch number, signals, prior touches) and NO content — the backend does not write prose. Author the message from scratch: verify research freshness first — the review item carries `person_researched_at`, `company_researched_at`, and `research_status`, so no extra fetch is needed (missing or >14 days stale → research with WebSearch and the research reads, persist findings via `research` action=save_person, and action=save_company for company-level findings), then write the touch in the seller's voice to the same standards below, check it with `check_prose` (`{item_id: <message id>, item_type: "message", content: <draft>}` — the `failures[]` are an advisory checklist: fix what you agree with; severity `block` failures are hard stops — channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener)), and submit it with `manage_messages` action=edit (content + the returned `rules_version` as `client_rules_version`) — that turns it into a normal draft for the orchestrator's approval flow. If the prospect is a bad fit on fresh evidence, recommend skip with a one-line `reason` instead of writing (reasons feed the prose_labels corpus).
|
|
7
|
+
**needs_draft items are authoring jobs, not reviews.** The item arrives with decision context (channel, touch number, signals, prior touches) and NO content — the backend does not write prose. Author the message from scratch: verify research freshness first — the review item carries `person_researched_at`, `company_researched_at`, and `research_status`, so no extra fetch is needed (missing or >14 days stale → research with WebSearch and the research reads, persist findings via `research` action=save_person with `payload.person_id` set to the review item's person UUID (update-only; the response's `updated_fields` confirms what landed — never pass the UUID as the facade `id` argument), and action=save_company for company-level findings), then write the touch in the seller's voice to the same standards below, check it with `check_prose` (`{item_id: <message id>, item_type: "message", content: <draft>}` — the `failures[]` are an advisory checklist: fix what you agree with; severity `block` failures are hard stops — channel character caps (cut to fit) and prior-outreach references on a first content touch (rewrite as an opener)), and submit it with `manage_messages` action=edit (content + the returned `rules_version` as `client_rules_version`) — that turns it into a normal draft for the orchestrator's approval flow. If the prospect is a bad fit on fresh evidence, recommend skip with a one-line `reason` instead of writing (reasons feed the prose_labels corpus).
|
|
8
8
|
|
|
9
9
|
**Frame by conversation state, not touch number.** The review item's `conversation_state` block (when present) is the truth about what this person has actually received: `first_content_touch=true` means their entire history is connection requests — the message is an OPENER whatever its T-number; never reference prior outreach ("my last note", "since I haven't heard back", "bumping this" bounce at the gate). `channel_fallback=true` means the step was designed for `designed_channel` — author for the item's actual `channel` and make the copy stand alone. `prior_context='note_only'` → don't repeat the `connection_note_text`; `prior_context='inbound_only'` → continue THEIR conversation (their message is referenceable; "my previous outreach" is not). The block is frozen at draft time — if the conversation_thread shows newer inbound or delivered content, trust the thread. Block absent or `v` unrecognized → fall back to the T-number heuristic. If prior contact happened outside Vruum, flag it to the user instead of resubmitting — the gate override is restricted and your resubmission will bounce again.
|
|
10
10
|
|
|
@@ -61,6 +61,9 @@ Stable operator-visible codes:
|
|
|
61
61
|
| `company_resolution_failed` | company resolver returned no canonical row | retry once, then inspect resolver logs and evidence |
|
|
62
62
|
| `company_research_save_failed` | company research persistence failed with unknown commit status | inspect stored rows before any replay |
|
|
63
63
|
| `person_not_visible` | supplied person is outside the caller's tenant | use a tenant-visible person or omit `person_id` |
|
|
64
|
+
| `person_not_found_for_update` | save_person anchors matched no one in your pipeline (update-only) | search and pass `payload.person_id`, or use save_discovered's `person` block for a new prospect |
|
|
65
|
+
| `identity_unverifiable` | person has neither a LinkedIn URL nor an email | include `person.linkedin_url` or `person.email` |
|
|
66
|
+
| `discovery_daily_cap` | rolling 24h discovery-save cap reached | resume tomorrow; do not retry this session |
|
|
64
67
|
| `person_not_found` | tenant membership points to a missing person | refresh the candidate list |
|
|
65
68
|
| `person_identity_conflict` | fallback identifier belongs to another person | remove the conflicting identifier and review the provider result |
|
|
66
69
|
| `person_research_save_failed` | person research persistence failed with unknown commit status | inspect stored rows before any replay |
|
|
@@ -140,7 +143,7 @@ Helps operators distinguish "still working" from "stuck."
|
|
|
140
143
|
|
|
141
144
|
**Concurrency cap: 5 parallel** (lowered from Phase A's 10 because Phase B subagents call `research` action=linkedin_fetch and the Unipile rate limiter throws over cap — see `backend/app/domains/channels/services/unipile/rate_limiter.py:36`. Lower concurrency keeps us under the per-account window.)
|
|
142
145
|
|
|
143
|
-
**Malformed LinkedIn fallback:** if the selected candidate already has a Vruum `person_id` and LinkedIn returns an invalid/malformed-profile result, preserve that `person_id` and retry the enrichment once through the first allowed structured provider in `source_policy` (Clay when selected/connected). Pass the same `person_id` to `research(action="save_person")
|
|
146
|
+
**Malformed LinkedIn fallback:** if the selected candidate already has a Vruum `person_id` and LinkedIn returns an invalid/malformed-profile result, preserve that `person_id` and retry the enrichment once through the first allowed structured provider in `source_policy` (Clay when selected/connected). Pass the same `person_id` in the PAYLOAD to `research(action="save_person", payload={person_id: ..., ...})` — update-only; never as the facade `id` argument. This is a provider fallback for one identity, not a new-person discovery. Never fall back on LinkedIn 429/rate-limit responses or timeouts; surface those for a later retry. If the fallback's email or LinkedIn URL belongs to another person, the backend returns `person_identity_conflict`; stop and surface it rather than dropping `person_id` and creating a duplicate.
|
|
144
147
|
|
|
145
148
|
Dispatch one `vruum-prospect-deep-researcher` per surviving candidate. Subagent file at `.claude/agents/vruum-prospect-deep-researcher.md`.
|
|
146
149
|
|
|
@@ -225,42 +228,29 @@ Per surviving prospect:
|
|
|
225
228
|
### a. Save company research (once per company)
|
|
226
229
|
If the prospect's company isn't already cached and Phase A produced fresh research, call `research(action="save_company", payload={name: <Phase A COMPANY>, website: <Phase A DOMAIN or canonical URL>, funding_data, growth_metrics, current_priorities: <newline-joined descriptions + source URLs>})`. The API field is `name`, not `company_name`; it accepts `website`, not `domain`; and `current_priorities` is one string, so serialize the Phase A object list instead of passing the list through. Skip if `CACHE_HIT: true` for that company.
|
|
227
230
|
|
|
228
|
-
### b. Identity
|
|
231
|
+
### b. Identity prep (names + company linkage for the atomic save)
|
|
229
232
|
|
|
230
|
-
|
|
233
|
+
**VRU-722 atomic flow:** `research` action=save_person is UPDATE-ONLY (refreshing
|
|
234
|
+
research on someone already saved). New prospects are created by ONE
|
|
235
|
+
`manage_person` action=save_discovered call carrying a `person` block — person,
|
|
236
|
+
research, and pipeline membership land in a single transaction, so a crashed or
|
|
237
|
+
rejected save persists nothing. There is no create-then-adopt dance anymore.
|
|
231
238
|
|
|
232
239
|
1. **Split full_name** if `first_name`/`last_name` aren't already set:
|
|
233
240
|
- Last-space heuristic: split on the last space. `Jane Smith` → first=`Jane`, last=`Smith`. `Jane van der Merwe` → first=`Jane`, last=`van der Merwe`.
|
|
234
241
|
- **Override with Phase B canonical names** if the linkedin_fetch research call returned them. LinkedIn's `first_name`/`last_name` fields are authoritative; the heuristic is a fallback for candidates without `linkedin_url`.
|
|
235
242
|
|
|
236
|
-
2. **
|
|
243
|
+
2. **Prepare company linkage** — the `person` block must identify the company unambiguously, ONE of:
|
|
237
244
|
|
|
238
|
-
**Path A (preferred):
|
|
245
|
+
**Path A (preferred): `company_id`.** Run the save_company call (`research` action=save_company) first, capture the returned `company_id`.
|
|
239
246
|
|
|
240
|
-
**Path B
|
|
247
|
+
**Path B: `company_name` + at least one anchor** (`company_domain`, `company_website`, or `company_linkedin_url`). The data is in the LinkedIn payload you already fetched. The prospect's CURRENT employer is the entry in `work_experience[]` with `end_date: null` — that entry has `company_linkedin_url`. If you ran linkedin_fetch with `include_company: true`, the company response carries `website` and `industry`. **Anchor-less name-only saves are rejected with HTTP 422.**
|
|
241
248
|
|
|
242
|
-
|
|
249
|
+
3. **Refreshing someone ALREADY saved** (e.g. operator pasted a Vruum person UUID, or a triage-time research refresh): call `research(action="save_person", payload={person_id: <uuid>, ...fresh research fields})` — update-in-place, `researched_at` moves, and the response's `updated_fields`/`skipped_fields` tell you exactly what landed (contact fields are backfill-only; corrections go through `manage_person` action=update_contact). NEVER pass the UUID as the facade `id` argument — save_person takes no `id` and will 422. If save_person returns 404 `person_not_found_for_update`, the person isn't saved yet — use the step-c atomic save instead.
|
|
243
250
|
|
|
244
|
-
|
|
245
|
-
```
|
|
246
|
-
research(
|
|
247
|
-
action="save_person",
|
|
248
|
-
payload={
|
|
249
|
-
first_name=..., last_name=...,
|
|
250
|
-
email=..., linkedin_url=...,
|
|
251
|
-
# ONE of:
|
|
252
|
-
company_id=<from the save_company call>
|
|
253
|
-
# OR:
|
|
254
|
-
company_name=..., company_linkedin_url=..., # at least one anchor
|
|
255
|
-
# ...rest of research fields
|
|
256
|
-
}
|
|
257
|
-
)
|
|
258
|
-
```
|
|
251
|
+
**Old-backend fallback (rollout window only):** if `save_discovered` answers with a bare `person_id: field required` 422, the backend predates this flow — fall back to the old two-step dance (save_person to create, then save_discovered with the returned person_id) until the promote lands.
|
|
259
252
|
|
|
260
|
-
|
|
261
|
-
- The response includes the `person_id`. Capture it for step c.
|
|
262
|
-
|
|
263
|
-
### c. Save discovered person (authoritative harness score, backend-enforced gate)
|
|
253
|
+
### c. Save discovered person — ONE atomic call (authoritative harness score)
|
|
264
254
|
|
|
265
255
|
Build the authoritative `assessment` from the campaign playbook plus Phase A/B evidence. Score mechanically so reruns agree:
|
|
266
256
|
|
|
@@ -301,15 +291,37 @@ The score is the sum (0–100); 70+ passes. Send this exact shape:
|
|
|
301
291
|
|
|
302
292
|
`match_summary` must be non-empty. Alignment items require `point` and `evidence`; concern items require `concern` and `evidence`. Confidence values are 0–1 and concern severity is exactly `blocker`, `warning`, or `minor`.
|
|
303
293
|
|
|
304
|
-
Then call `manage_person(action="save_discovered", payload
|
|
294
|
+
Then call `manage_person(action="save_discovered", ...)` with ONE of two payload shapes (pass exactly one of `person` / `person_id`):
|
|
295
|
+
|
|
296
|
+
**NEW prospect (the normal Step 7 case):**
|
|
297
|
+
```
|
|
298
|
+
manage_person(
|
|
299
|
+
action="save_discovered",
|
|
300
|
+
payload={
|
|
301
|
+
person={
|
|
302
|
+
first_name=..., last_name=...,
|
|
303
|
+
email=..., linkedin_url=...,
|
|
304
|
+
# company linkage from step b (ONE of):
|
|
305
|
+
company_id=<from save_company> # OR company_name + an anchor
|
|
306
|
+
# ...rest of research fields (headline, seniority_level,
|
|
307
|
+
# topics_of_interest, recent_posts, role_start_date, ...)
|
|
308
|
+
},
|
|
309
|
+
assessment=<object above>, # REQUIRED with person
|
|
310
|
+
campaign_id=... or assessment_campaign_id=... # a campaign ref is REQUIRED
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Person already saved:** `payload={person_id: <uuid>, assessment: <object above>, ...}` — applies the score update-in-place (THE path to score an existing stub).
|
|
305
316
|
|
|
306
317
|
- `mode == save`: add `assessment_campaign_id: <campaign>` so the score is recorded against the campaign ICP, and omit `campaign_id` so no assignment or move occurs. New rows remain unassigned; duplicates keep their existing campaign assignment.
|
|
307
318
|
- `mode == save-and-enroll`: add `campaign_id: <campaign>`; the backend uses it for both assessment provenance and assignment. Omit `assessment_campaign_id` unless it is the same campaign.
|
|
308
319
|
|
|
309
320
|
This:
|
|
321
|
+
- Creates person + research + pipeline membership in ONE transaction (person shape) — a failed or rejected save persists nothing, so there is no orphan window
|
|
310
322
|
- Records the harness assessment as authoritative and skips the backend LLM scorer
|
|
311
|
-
-
|
|
312
|
-
-
|
|
323
|
+
- Dedupes on canonical anchors: if the person block's email/linkedin match someone already saved (any URL variant — www, trailing slash, encoding), the call continues as a duplicate update instead of creating
|
|
324
|
+
- Returns `person_id` (capture it for step d), `match_score` (0–100), `quality_gate_pass` (bool, true iff `match_score >= 70`), and `warnings[]` naming any failed best-effort side effects
|
|
313
325
|
|
|
314
326
|
**Distinguish two failure modes (Codex Finding #9):**
|
|
315
327
|
- **Request failure (5xx, timeout, network):** retry once with 2s backoff. If still failing, leave the prospect in `discovery_failed` status and surface in the final report. **Don't** claim "saved as gate-fail" — the row was never written.
|