outreach: Outreach Draft Assistant — tailored LP drafts (v0.1.0:68)
First proactive-messaging build. New "Outreach" page (all authenticated users): pick an investor + type (intro / follow-up / fund update / meeting follow-up / nurture) + optional guidance; the agent drafts a tailored LP email in Ten31's voice, grounded in the thesis + that investor's CRM notes and matched email history. The draft is editable + copyable; nothing is sent (draft-only — guardrails #4, #6). Sovereignty: the thesis is Ten31's own non-sensitive messaging (to Claude as-is); the LP context is scrubbed through the redaction boundary before Claude, drafted with placeholders, and re-hydrated locally — the LP list never reaches the API. Fails closed (scrub_unavailable / claude_not_configured / rehydrate_failed quarantines a hallucinated-token draft). Backend: mcp/outreach_agent.py (context assembly + scrub + Claude + rehydrate, reusing architect_agent's client/thesis/voice + the Boundary); routes GET /api/outreach/investors, POST /api/outreach/draft; logged. Test mcp/test_outreach.py (context assembly). Verified in preview: page/selector/types/guidance render, fail-closed at the key-less Claude step (scrub ran locally first), success rendering verified with a mocked ok draft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Outreach drafting agent — tailored LP outreach in Ten31's voice, grounded in the
|
||||
thesis + the LP's DE-IDENTIFIED context, through the redaction boundary.
|
||||
|
||||
Draft-only: a human reviews, edits, and sends (guardrails #4 and #6 — no auto-send,
|
||||
no cold/outbound automation until counsel defines the solicitation posture). Sovereignty:
|
||||
the thesis is Ten31's own non-sensitive messaging and goes to Claude as-is; the LP's
|
||||
context (CRM notes + email history) is scrubbed first, so the LP list never reaches the
|
||||
API in the clear, and the draft is re-hydrated locally for the human.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# outreach_type -> human description woven into the prompt
|
||||
OUTREACH_TYPES = {
|
||||
"intro": "a first introduction to Ten31 and the fund",
|
||||
"follow_up": "a warm follow-up that moves the conversation forward",
|
||||
"fund_update": "a fund update / progress note",
|
||||
"meeting_follow_up": "a follow-up after a recent meeting or call",
|
||||
"nurture": "a light-touch note to stay in contact",
|
||||
}
|
||||
|
||||
|
||||
def _context(conn, investor_id):
|
||||
"""Assemble the recipient's context: CRM notes + recent matched email with them.
|
||||
Returns (investor_name, context_text) or (None, None)."""
|
||||
row = conn.execute("SELECT investor_name, notes FROM fundraising_investors WHERE id=?",
|
||||
(investor_id,)).fetchone()
|
||||
if not row:
|
||||
return None, None
|
||||
name = row["investor_name"]
|
||||
parts = [f"Investor: {name}"]
|
||||
notes = (row["notes"] or "").strip()
|
||||
if notes:
|
||||
parts.append("CRM notes:\n" + notes)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT e.subject, e.body_text, e.snippet, e.sent_at FROM emails e "
|
||||
"JOIN email_investor_links l ON l.email_id = e.id "
|
||||
"WHERE l.fundraising_investor_id = ? AND e.is_matched = 1 "
|
||||
"ORDER BY e.sent_at DESC LIMIT 6", (investor_id,)).fetchall()
|
||||
for em in rows:
|
||||
body = (em["body_text"] or em["snippet"] or "")[:1500].strip()
|
||||
if body or em["subject"]:
|
||||
parts.append(f"Email ({(em['sent_at'] or '')[:10]}) — {em['subject'] or '(no subject)'}\n{body}")
|
||||
except Exception:
|
||||
pass # email tables may be absent / not yet captured
|
||||
return name, "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def _draft_with_claude(aa, thesis, type_desc, deident_context, guidance):
|
||||
system = (
|
||||
"You are Ten31's outreach copilot. Draft ONE ready-to-send LP outreach email in Ten31's voice. "
|
||||
f"VOICE RULES (follow exactly): {aa.VOICE}\n\n"
|
||||
"Ten31 invests in critical infrastructure across bitcoin, AI, energy, and freedom technologies, "
|
||||
"with scarcity as the connecting idea. Current working thesis:\n" + aa._render_thesis(thesis) + "\n\n"
|
||||
"The recipient's context below is DE-IDENTIFIED: people, firms, and amounts appear as placeholders "
|
||||
"like [PERSON_1], [ORG_1], [AMOUNT_1]. Keep every placeholder EXACTLY as written and NEVER invent new "
|
||||
"ones — they are swapped back to real values after you reply. Output a subject line, then the email body. "
|
||||
"Ground it in the actual context; do NOT fabricate facts, numbers, returns, or commitments that are not "
|
||||
"present in the context or the thesis.")
|
||||
user = (f"Outreach type: {type_desc}\n\n"
|
||||
f"Recipient context (de-identified):\n{deident_context}\n\n"
|
||||
+ (f"Additional guidance from the sender: {guidance}\n\n" if (guidance or "").strip() else "")
|
||||
+ "Draft the email now.")
|
||||
resp = aa._client().messages.create(
|
||||
model=aa.MODEL, max_tokens=1200,
|
||||
system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
|
||||
messages=[{"role": "user", "content": user}])
|
||||
return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
|
||||
|
||||
|
||||
def draft_outreach(conn, investor_id, outreach_type, guidance, db_path):
|
||||
"""Draft tailored outreach for one investor. FAILS CLOSED: if the scrub can't be
|
||||
prepared or Claude hallucinates a placeholder, no de-anonymized draft is returned."""
|
||||
name, context = _context(conn, investor_id)
|
||||
if not name:
|
||||
return {"status": "not_found"}
|
||||
type_desc = OUTREACH_TYPES.get(outreach_type, OUTREACH_TYPES["follow_up"])
|
||||
|
||||
# 1) Scrub the LP context — the LP list / identifiers never reach Claude in the clear.
|
||||
try:
|
||||
sys.path.insert(0, os.path.dirname(_HERE)) # backend/ for the redaction package
|
||||
from redaction.client import Boundary
|
||||
boundary = Boundary(db_path=db_path, actor="closer")
|
||||
scrubbed = boundary.scrub([context], bucket=False, conn=conn)
|
||||
except Exception as exc:
|
||||
return {"status": "scrub_unavailable", "reason": str(exc)}
|
||||
deident = scrubbed["items"][0]
|
||||
handle = scrubbed["handle"]
|
||||
|
||||
# 2) Claude drafts over the de-identified context + (non-sensitive) thesis.
|
||||
try:
|
||||
sys.path.insert(0, _HERE)
|
||||
import architect_agent as aa
|
||||
thesis = aa.at.get_thesis("core", db=db_path)
|
||||
raw = _draft_with_claude(aa, thesis, type_desc, deident, guidance)
|
||||
except Exception as exc:
|
||||
boundary.forget(handle)
|
||||
return {"status": "claude_not_configured", "reason": str(exc)}
|
||||
|
||||
# 3) Re-hydrate locally (strict: a hallucinated placeholder quarantines the draft).
|
||||
rehy = boundary.rehydrate(raw, handle, strict=True, conn=conn)
|
||||
boundary.forget(handle)
|
||||
if rehy.get("error"):
|
||||
return {"status": "rehydrate_failed"}
|
||||
return {"status": "ok", "draft": rehy["text"], "investor_name": name,
|
||||
"scrub_stats": scrubbed.get("stats", {})}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test the outreach agent's context assembly: it pulls the investor's CRM notes +
|
||||
recent matched email into the de-identifiable context block. Synthetic data only
|
||||
(guardrail #9). The scrub/Claude/rehydrate round-trip is exercised live in the preview.
|
||||
Run: cd backend && python3 mcp/test_outreach.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import outreach_agent as oa # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def main():
|
||||
db = os.path.join(tempfile.mkdtemp(), "t.db")
|
||||
c = sqlite3.connect(db)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.executescript("""
|
||||
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, notes TEXT);
|
||||
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, sent_at TEXT, is_matched INT);
|
||||
CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT);
|
||||
""")
|
||||
c.execute("INSERT INTO fundraising_investors VALUES ('inv1','Harbor & Vine','Met at the conference; interested in Fund III.')")
|
||||
c.executemany("INSERT INTO emails (id,subject,body_text,sent_at,is_matched) VALUES (?,?,?,?,1)", [
|
||||
("e1", "Re: Fund III", "Thanks for the call. We are still weighing the lock-up terms.", "2026-06-02T10:00:00"),
|
||||
("e2", "Intro", "Good to meet you at the dinner.", "2026-05-01T10:00:00"),
|
||||
("e3", "Spam", "ignore me", "2026-04-01T10:00:00"), # not linked -> excluded
|
||||
])
|
||||
c.executemany("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id) VALUES (?,?, 'inv1')",
|
||||
[("l1", "e1"), ("l2", "e2")])
|
||||
c.commit()
|
||||
|
||||
name, ctx = oa._context(c, "inv1")
|
||||
check(name == "Harbor & Vine", f"resolves investor name (got {name!r})")
|
||||
check("Met at the conference" in ctx, "includes CRM notes")
|
||||
check("lock-up terms" in ctx, "includes matched email body")
|
||||
check("Good to meet you" in ctx, "includes a second matched email")
|
||||
check("ignore me" not in ctx, "excludes email not linked to this investor")
|
||||
check(ctx.index("lock-up terms") < ctx.index("Good to meet you"), "newest email first")
|
||||
|
||||
n2, c2 = oa._context(c, "missing")
|
||||
check(n2 is None and c2 is None, "unknown investor -> (None, None)")
|
||||
|
||||
# type catalogue is intact
|
||||
check(set(["intro", "follow_up", "fund_update", "meeting_follow_up", "nurture"]) <= set(oa.OUTREACH_TYPES),
|
||||
"outreach types catalogue present")
|
||||
|
||||
if FAILS:
|
||||
print(f"\nFAILED ({len(FAILS)})")
|
||||
for f in FAILS:
|
||||
print(" - " + f)
|
||||
sys.exit(1)
|
||||
print("\nALL PASS (outreach context assembly)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -60,10 +60,12 @@ try:
|
||||
import architect_tools as _architect_tools # type: ignore
|
||||
import architect_agent as _architect_agent # type: ignore
|
||||
import architect_grounding as _architect_grounding # type: ignore
|
||||
import outreach_agent as _outreach_agent # type: ignore
|
||||
except Exception:
|
||||
_architect_tools = None
|
||||
_architect_agent = None
|
||||
_architect_grounding = None
|
||||
_outreach_agent = None
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1804,6 +1806,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_system_status(user)
|
||||
if path == '/api/activity/proposals':
|
||||
return self.handle_list_activity_proposals(user)
|
||||
if path == '/api/outreach/investors':
|
||||
return self.handle_list_outreach_investors(user)
|
||||
|
||||
# Users
|
||||
if path == '/api/users':
|
||||
@@ -1907,6 +1911,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_node_feedback(user, path.split('/')[-2], body)
|
||||
if path == '/api/architect/ground':
|
||||
return self.handle_architect_ground(user, body)
|
||||
if path == '/api/outreach/draft':
|
||||
return self.handle_outreach_draft(user, body)
|
||||
if re.match(r'^/api/activity/proposals/[^/]+/approve$', path):
|
||||
return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body)
|
||||
if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path):
|
||||
@@ -3905,6 +3911,44 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return self.send_json({"data": res})
|
||||
|
||||
def handle_list_outreach_investors(self, user):
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute("SELECT id, investor_name FROM fundraising_investors "
|
||||
"ORDER BY investor_name LIMIT 2000").fetchall()
|
||||
return self.send_json({"investors": [{"id": r["id"], "name": r["investor_name"]} for r in rows]})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def handle_outreach_draft(self, user, body):
|
||||
"""Draft tailored LP outreach through the redaction boundary (draft-only —
|
||||
a human reviews/edits/sends; guardrails #4, #6)."""
|
||||
if _outreach_agent is None:
|
||||
return self.send_error_json("Outreach agent unavailable", 503)
|
||||
body = body or {}
|
||||
inv = body.get('investor_id')
|
||||
if not inv:
|
||||
return self.send_error_json("investor_id required", 400)
|
||||
conn = get_db()
|
||||
try:
|
||||
res = _outreach_agent.draft_outreach(conn, inv, body.get('outreach_type', 'follow_up'),
|
||||
body.get('guidance', '') or '', DB_PATH)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO interaction_log (id, ts, actor_type, actor_id, action, target_type, target_id, payload, source, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(generate_id(), now(), "human", user.get('user_id'), "outreach.drafted",
|
||||
"fundraising_investor", inv,
|
||||
json.dumps({"type": body.get('outreach_type'), "status": res.get('status')}), "crm_ui", now()))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
return self.send_error_json(str(exc), 502)
|
||||
finally:
|
||||
conn.close()
|
||||
return self.send_json({"data": res})
|
||||
|
||||
# ─── Architect thesis (Phase 1) ───
|
||||
def handle_list_thesis_lines(self, user):
|
||||
if thesis_review is None:
|
||||
|
||||
@@ -9948,6 +9948,132 @@
|
||||
);
|
||||
};
|
||||
|
||||
const OutreachPage = ({ token, user, onShowToast }) => {
|
||||
const [investors, setInvestors] = useState([]);
|
||||
const [investorId, setInvestorId] = useState('');
|
||||
const [type, setType] = useState('follow_up');
|
||||
const [guidance, setGuidance] = useState('');
|
||||
const [drafting, setDrafting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const TYPES = [
|
||||
['intro', 'Intro'],
|
||||
['follow_up', 'Warm follow-up'],
|
||||
['fund_update', 'Fund update'],
|
||||
['meeting_follow_up', 'Meeting follow-up'],
|
||||
['nurture', 'Nurture / stay in touch'],
|
||||
];
|
||||
const FAIL = {
|
||||
not_found: 'That investor was not found.',
|
||||
scrub_unavailable: 'The redaction boundary could not be prepared, so nothing was sent to Claude.',
|
||||
claude_not_configured: 'The Architect (Claude) is not configured on the server.',
|
||||
rehydrate_failed: 'The draft could not be safely personalized (an unexpected placeholder). Try again.',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api('/api/outreach/investors', {}, token);
|
||||
if (!cancelled) setInvestors(Array.isArray(r?.investors) ? r.investors : []);
|
||||
} catch (_) { /* none */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token]);
|
||||
|
||||
const draft = async () => {
|
||||
if (drafting) return;
|
||||
if (!investorId) { onShowToast('Pick an investor first', 'error'); return; }
|
||||
try {
|
||||
setDrafting(true);
|
||||
setResult(null);
|
||||
const res = await api('/api/outreach/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ investor_id: investorId, outreach_type: type, guidance }),
|
||||
}, token);
|
||||
const data = res.data || res;
|
||||
setResult(data);
|
||||
if (data.status === 'ok') setDraftText(data.draft || '');
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err, 'Drafting failed');
|
||||
setResult({ status: 'error', reason: msg });
|
||||
onShowToast(msg, 'error');
|
||||
} finally {
|
||||
setDrafting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(draftText); onShowToast('Draft copied', 'success'); }
|
||||
catch (_) { onShowToast('Could not copy', 'error'); }
|
||||
};
|
||||
|
||||
const ok = result && result.status === 'ok';
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
|
||||
<div className="section">
|
||||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||||
Drafts a tailored LP email in Ten31's voice, grounded in the thesis and that investor's CRM notes + email history. The investor's details are de-identified before Claude sees them and restored locally, so the LP list never leaves Ten31. Drafts only — you review, edit, and send.
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
|
||||
<label className="form-label">Investor</label>
|
||||
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
|
||||
<option value="">Select an investor…</option>
|
||||
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '12px', maxWidth: '420px' }}>
|
||||
<label className="form-label">Type</label>
|
||||
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '12px' }}>
|
||||
<label className="form-label">Guidance (optional)</label>
|
||||
<textarea className="text-input" style={{ width: '100%', minHeight: '54px' }}
|
||||
placeholder="e.g. mention the new Giga deal; they asked about lock-up terms"
|
||||
value={guidance} onChange={(e) => setGuidance(e.target.value)} />
|
||||
</div>
|
||||
<div className="index-action-buttons">
|
||||
<button onClick={draft} disabled={drafting || !investorId}>
|
||||
{drafting ? 'Drafting… (this can take a moment)' : 'Draft outreach'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drafting && <div className="section"><SkeletonBlock lines={6} /></div>}
|
||||
|
||||
{result && !drafting && (
|
||||
<div className="section">
|
||||
{ok ? (
|
||||
<>
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
Draft for {result.investor_name}
|
||||
{result.scrub_stats && result.scrub_stats.tokens != null
|
||||
? <span className="approval-pill">{result.scrub_stats.tokens} identifiers protected</span> : null}
|
||||
</div>
|
||||
<textarea className="text-input" style={{ width: '100%', minHeight: '260px', lineHeight: 1.5 }}
|
||||
value={draftText} onChange={(e) => setDraftText(e.target.value)} />
|
||||
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
|
||||
<button onClick={copy}>Copy draft</button>
|
||||
</div>
|
||||
<div className="index-action-hint" style={{ marginTop: '10px' }}>
|
||||
Review and edit before sending. Nothing is sent automatically.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="toast error" style={{ position: 'static' }}>
|
||||
{FAIL[result.status] || result.reason || 'Drafting did not complete.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailCapturePage = ({ token, user, onShowToast }) => {
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [status, setStatus] = useState(null);
|
||||
@@ -10751,6 +10877,9 @@
|
||||
<button className={`nav-item ${page === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}>
|
||||
<span className="nav-item-icon">◆</span> Thesis Workshop
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'outreach' ? 'active' : ''}`} onClick={() => setPage('outreach')}>
|
||||
<span className="nav-item-icon">✎</span> Outreach
|
||||
</button>
|
||||
<button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
|
||||
<span className="nav-item-icon">◉</span> System Status
|
||||
</button>
|
||||
@@ -10786,6 +10915,7 @@
|
||||
{page === 'communications' && 'Communications'}
|
||||
{page === 'thesis' && 'Thesis'}
|
||||
{page === 'thesis-workshop' && 'Thesis Workshop'}
|
||||
{page === 'outreach' && 'Outreach'}
|
||||
{page === 'system-status' && 'System Status'}
|
||||
{page === 'email-capture' && 'Email Capture'}
|
||||
{page === 'feature-requests' && 'Feature Requests'}
|
||||
@@ -10818,6 +10948,7 @@
|
||||
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
|
||||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'email-capture' && <EmailCapturePage token={token} user={user} onShowToast={showToast} />}
|
||||
{page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
|
||||
|
||||
@@ -32,8 +32,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
||||
// * 0.1.0:64 (email-activity agent: propose->review->approve grid notes; sync ~15 min)
|
||||
// * 0.1.0:65 (Email Capture: per-mailbox captured/matched counts)
|
||||
// * 0.1.0:66 (LP Objections page: UI trigger for the Architect grounding pass)
|
||||
// * Current: 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
||||
export const PACKAGE_VERSION = '0.1.0:67'
|
||||
// * 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
||||
// * Current: 0.1.0:68 (Outreach Draft Assistant — tailored LP drafts via thesis + redaction boundary)
|
||||
export const PACKAGE_VERSION = '0.1.0:68'
|
||||
|
||||
export const DATA_MOUNT_PATH = '/data'
|
||||
export const WEB_PORT = 8080
|
||||
|
||||
@@ -28,8 +28,9 @@ import { v_0_1_0_64 } from './v0.1.0.64'
|
||||
import { v_0_1_0_65 } from './v0.1.0.65'
|
||||
import { v_0_1_0_66 } from './v0.1.0.66'
|
||||
import { v_0_1_0_67 } from './v0.1.0.67'
|
||||
import { v_0_1_0_68 } from './v0.1.0.68'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0_67,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66],
|
||||
current: v_0_1_0_68,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Outreach Draft Assistant. Pick an investor + an outreach type (intro / follow-up /
|
||||
// fund update / meeting follow-up / nurture) + optional guidance, and the agent drafts
|
||||
// a tailored LP email in Ten31's voice, grounded in the thesis + that investor's CRM
|
||||
// notes and email history. The investor's context is de-identified through the redaction
|
||||
// boundary before Claude sees it and restored locally — the LP list never leaves Ten31.
|
||||
// Draft-only: a human reviews, edits, and sends (no auto-send; guardrails #4, #6). New
|
||||
// backend mcp/outreach_agent.py + routes GET /api/outreach/investors, POST /api/outreach/draft.
|
||||
export const v_0_1_0_68 = VersionInfo.of({
|
||||
version: '0.1.0:68',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'New Outreach page: pick an investor and a type, and the Architect drafts a tailored',
|
||||
'email in Ten31’s voice using the thesis + that investor’s notes and email history. Their',
|
||||
'details are de-identified before Claude sees them and restored locally. Drafts only —',
|
||||
'you review, edit, and send. Nothing is sent automatically.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user