Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07af9257f4 | |||
| fffc90c7a4 | |||
| c53fdcb4a0 | |||
| 606b336a00 | |||
| 49f84ca9a4 | |||
| 787d580550 | |||
| b5619d61e1 | |||
| 0943aeb2df | |||
| c2b84a1f26 | |||
| 701e37b579 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "crm-preview",
|
||||
"runtimeExecutable": "bash",
|
||||
"runtimeArgs": ["-c", "CRM_DB_PATH=/tmp/crm_preview.db CRM_DATA_DIR=/tmp/crm_preview_data CRM_FRONTEND_DIR=/Users/macpro/Projects/CRM/frontend CRM_PORT=8765 CRM_ENV=development exec python3 backend/server.py"],
|
||||
"port": 8765
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Create a Gmail DRAFT (never send) in the sender's mailbox via domain-wide delegation
|
||||
with the gmail.compose scope. Lets an approved Outreach draft land in the user's Gmail
|
||||
(and therefore Superhuman) Drafts — as an in-thread reply when there's an active thread,
|
||||
or a fresh email otherwise. The human reviews and sends from Gmail (guardrails #4, #6 —
|
||||
our code only ever creates a draft, it never sends).
|
||||
"""
|
||||
import base64
|
||||
import email.message
|
||||
import json as _json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import config as _cfg
|
||||
from . import credentials as _creds
|
||||
|
||||
|
||||
def _parse_subject_body(draft_text):
|
||||
"""Split a draft of the form 'Subject: ...\\n\\n<body>' into (subject, body)."""
|
||||
text = (draft_text or "").strip()
|
||||
lines = text.split("\n")
|
||||
if lines and lines[0].lower().startswith("subject:"):
|
||||
subject = lines[0].split(":", 1)[1].strip()
|
||||
body = "\n".join(lines[1:]).lstrip("\n")
|
||||
return subject, body
|
||||
return "", text
|
||||
|
||||
|
||||
def _reply_target(conn, investor_id):
|
||||
"""LP address + active-thread headers for an in-thread reply, from the most recent
|
||||
matched email with this investor. Returns {to, thread_id, in_reply_to} or None."""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT e.rfc_message_id, e.gmail_thread_id, l.matched_address "
|
||||
"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 1", (investor_id,)).fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
if not row or not row["matched_address"]:
|
||||
return None
|
||||
return {"to": row["matched_address"], "thread_id": row["gmail_thread_id"],
|
||||
"in_reply_to": row["rfc_message_id"]}
|
||||
|
||||
|
||||
def _build_raw(from_addr, to_addr, subject, body, in_reply_to=None):
|
||||
msg = email.message.EmailMessage()
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject or "(no subject)"
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to
|
||||
msg["References"] = in_reply_to
|
||||
msg.set_content(body or "")
|
||||
return base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii")
|
||||
|
||||
|
||||
def create_outreach_draft(conn, sender_email, investor_id, draft_text):
|
||||
"""Create a Gmail draft in `sender_email`'s mailbox addressed to the investor.
|
||||
Returns {status, ...}. Never sends."""
|
||||
if not sender_email:
|
||||
return {"status": "no_sender"}
|
||||
if not _cfg.CONFIG.enabled:
|
||||
return {"status": "integration_disabled"}
|
||||
subject, body = _parse_subject_body(draft_text)
|
||||
if not body.strip():
|
||||
return {"status": "empty"}
|
||||
target = _reply_target(conn, investor_id)
|
||||
if not target:
|
||||
return {"status": "no_recipient"} # no email history -> no LP address to draft to
|
||||
try:
|
||||
provider = _creds.build_provider(lambda: conn)
|
||||
token = provider.access_token_for(sender_email, _creds.GMAIL_COMPOSE_SCOPE).token
|
||||
except Exception as exc:
|
||||
return {"status": "auth_error", "reason": str(exc)}
|
||||
raw = _build_raw(sender_email, target["to"], subject, body, target.get("in_reply_to"))
|
||||
payload = {"message": {"raw": raw}}
|
||||
if target.get("thread_id"):
|
||||
payload["message"]["threadId"] = target["thread_id"]
|
||||
url = f"https://gmail.googleapis.com/gmail/v1/users/{urllib.parse.quote(sender_email)}/drafts"
|
||||
req = urllib.request.Request(url, data=_json.dumps(payload).encode("utf-8"), method="POST",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
_json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "gmail_error", "reason": e.read().decode("utf-8", "replace")[:300]}
|
||||
except Exception as exc:
|
||||
return {"status": "gmail_error", "reason": str(exc)}
|
||||
return {"status": "ok", "to": target["to"], "threaded": bool(target.get("thread_id")),
|
||||
"gmail_url": "https://mail.google.com/mail/u/0/#drafts"}
|
||||
@@ -32,6 +32,9 @@ from . import errors
|
||||
|
||||
|
||||
GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
# Drafts scope (authorized in Workspace DWD). We only ever CREATE drafts with it; the
|
||||
# human sends from Gmail. (Google bundles send into this scope, but our code never sends.)
|
||||
GMAIL_COMPOSE_SCOPE = "https://www.googleapis.com/auth/gmail.compose"
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
|
||||
|
||||
@@ -61,13 +64,14 @@ class DWDCredentialProvider:
|
||||
self._cache: dict[str, AccessToken] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def access_token_for(self, email_address: str) -> AccessToken:
|
||||
def access_token_for(self, email_address: str, scope: str = GMAIL_READONLY_SCOPE) -> AccessToken:
|
||||
key = f"{email_address}|{scope}"
|
||||
with self._lock:
|
||||
cached = self._cache.get(email_address)
|
||||
cached = self._cache.get(key)
|
||||
if cached and cached.expires_at - time.time() > 60:
|
||||
return cached
|
||||
token = self._mint(email_address)
|
||||
self._cache[email_address] = token
|
||||
token = self._mint(email_address, scope)
|
||||
self._cache[key] = token
|
||||
return token
|
||||
|
||||
def revoke(self, email_address: str) -> None:
|
||||
@@ -78,7 +82,7 @@ class DWDCredentialProvider:
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _mint(self, subject_email: str) -> AccessToken:
|
||||
def _mint(self, subject_email: str, scope: str = GMAIL_READONLY_SCOPE) -> AccessToken:
|
||||
try:
|
||||
from cryptography.hazmat.primitives import hashes, serialization # type: ignore
|
||||
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
||||
@@ -92,7 +96,7 @@ class DWDCredentialProvider:
|
||||
claim = {
|
||||
"iss": self._client_email,
|
||||
"sub": subject_email,
|
||||
"scope": GMAIL_READONLY_SCOPE,
|
||||
"scope": scope,
|
||||
"aud": GOOGLE_TOKEN_URL,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
|
||||
@@ -162,6 +162,22 @@ def _h_list_accounts(handler):
|
||||
"FROM email_accounts ORDER BY email_address"
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
# Per-mailbox counts: emails are de-duplicated globally, so "captured per
|
||||
# mailbox" comes from the per-account sighting table; "matched" joins to emails.
|
||||
captured, matched = {}, {}
|
||||
try:
|
||||
captured = {r["account_id"]: r["n"] for r in cur.execute(
|
||||
"SELECT account_id, COUNT(*) AS n FROM email_account_messages "
|
||||
"WHERE deleted_at IS NULL GROUP BY account_id")}
|
||||
matched = {r["account_id"]: r["n"] for r in cur.execute(
|
||||
"SELECT eam.account_id AS account_id, COUNT(*) AS n FROM email_account_messages eam "
|
||||
"JOIN emails e ON e.id = eam.email_id "
|
||||
"WHERE eam.deleted_at IS NULL AND e.is_matched = 1 GROUP BY eam.account_id")}
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for r in rows:
|
||||
r["captured"] = captured.get(r["id"], 0)
|
||||
r["matched"] = matched.get(r["id"], 0)
|
||||
finally:
|
||||
conn.close()
|
||||
# Non-admins only see their own row
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test the Gmail-draft message construction (the part that doesn't need live Gmail):
|
||||
subject/body parsing, reply-target resolution, and the RFC822 build incl. threading
|
||||
headers. The actual drafts.create call is exercised on the box. Synthetic data only.
|
||||
Run: cd backend && python3 email_integration/test_compose.py
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from email_integration import compose as cp # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def main():
|
||||
s, b = cp._parse_subject_body("Subject: Following up\n\nHi Sarah,\n\nthanks for the call.")
|
||||
check(s == "Following up" and b.startswith("Hi Sarah"), "parses 'Subject:' line + body")
|
||||
s2, b2 = cp._parse_subject_body("No subject prefix here")
|
||||
check(s2 == "" and b2 == "No subject prefix here", "no subject line -> empty subject, full body")
|
||||
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
c.executescript("""
|
||||
CREATE TABLE emails(id TEXT, rfc_message_id TEXT, gmail_thread_id TEXT, sent_at TEXT, is_matched INT);
|
||||
CREATE TABLE email_investor_links(email_id TEXT, fundraising_investor_id TEXT, matched_address TEXT);
|
||||
""")
|
||||
c.execute("INSERT INTO emails VALUES('e1','<m1@x>','t1','2026-06-01',1)")
|
||||
c.execute("INSERT INTO email_investor_links VALUES('e1','inv1','lp@harborvine.example')")
|
||||
c.commit()
|
||||
t = cp._reply_target(c, "inv1")
|
||||
check(t and t["to"] == "lp@harborvine.example" and t["thread_id"] == "t1" and t["in_reply_to"] == "<m1@x>",
|
||||
"reply target resolves LP address + thread + in-reply-to")
|
||||
check(cp._reply_target(c, "nope") is None, "no history -> no reply target")
|
||||
|
||||
raw = cp._build_raw("grant@ten31.xyz", "lp@x.example", "Hi", "Body text here", "<m1@x>")
|
||||
dec = base64.urlsafe_b64decode(raw).decode("utf-8", "replace")
|
||||
check("From: grant@ten31.xyz" in dec and "To: lp@x.example" in dec, "RFC822 has From + To")
|
||||
check("Subject: Hi" in dec and "Body text here" in dec, "RFC822 has Subject + body")
|
||||
check("In-Reply-To: <m1@x>" in dec and "References: <m1@x>" in dec, "threading headers set for replies")
|
||||
raw2 = cp._build_raw("a@b.co", "c@d.co", "", "body", None)
|
||||
dec2 = base64.urlsafe_b64decode(raw2).decode("utf-8", "replace")
|
||||
check("Subject: (no subject)" in dec2 and "In-Reply-To" not in dec2, "no subject / no thread -> fresh email")
|
||||
|
||||
if FAILS:
|
||||
print(f"\nFAILED ({len(FAILS)})")
|
||||
for f in FAILS:
|
||||
print(" - " + f)
|
||||
sys.exit(1)
|
||||
print("\nALL PASS (gmail compose message construction)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -58,7 +58,22 @@ def _render_thesis(thesis):
|
||||
def _system(thesis):
|
||||
text = ("You are the Architect, the in-house copilot that sharpens Ten31's investment "
|
||||
"thesis with the partners. Ten31 invests in critical infrastructure across bitcoin, "
|
||||
"AI, energy, and freedom technologies, with scarcity as the connecting idea. "
|
||||
"AI, energy, and freedom technologies. The spine of the thesis: fiat is being debased "
|
||||
"while AI drives the marginal cost of anything reproducible toward zero, so durable "
|
||||
"value migrates to what stays provably scarce and verifiable. Bitcoin is the apex form "
|
||||
"of that, a fixed-supply, non-debasable, verifiable reserve asset. AI is the abundance "
|
||||
"engine and bitcoin is the scarcity anchor, two faces of one megatrend. The throughline "
|
||||
"is an asset-value and capital-flow claim: as money debases and AI commoditizes the "
|
||||
"reproducible, value accrues to the scarce side of one supply chain and the monetary "
|
||||
"premium accrues to bitcoin as the non-debasable reserve asset. This is not a claim "
|
||||
"that the world transacts, settles, or clears in bitcoin. The structure runs on three "
|
||||
"seams: Energy and Compute (the same scarce firm power feeds both AI and bitcoin), "
|
||||
"Debasement and Bitcoin (bitcoin as reserve and as pristine collateral for credit, "
|
||||
"never payments), and AI and Data-Ownership (sovereign data and confidential inference, "
|
||||
"the own-your-stack and own-your-inference layer). Strike is a financial-services and "
|
||||
"reserve re-rate, never a payments story. Proof standard: every proof point must be "
|
||||
"falsifiable as scaled substance with a number, never a first-instance milestone. Do "
|
||||
"not invent proof and do not over-expose sensitive deal or return specifics. "
|
||||
f"VOICE RULES (follow exactly): {VOICE}\n\n"
|
||||
"Here is the current working thesis:\n" + _render_thesis(thesis))
|
||||
# Cache the thesis context so iterating across requests is cheap.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""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 json
|
||||
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 _days_between(then_iso, now_iso):
|
||||
from datetime import datetime
|
||||
try:
|
||||
a = datetime.strptime(str(then_iso)[:10], "%Y-%m-%d")
|
||||
b = datetime.strptime(str(now_iso)[:10], "%Y-%m-%d")
|
||||
return (b - a).days
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def follow_up_radar(conn, our_addresses, now_iso, warm_days=45, limit=60):
|
||||
"""Deterministic scan: surface investors who need attention, each with a concrete,
|
||||
checkable reason (no LLM guesswork in the *surfacing*). Tiers, most urgent first:
|
||||
0 you owe a reply (their email is the most recent, unanswered)
|
||||
1 flagged for follow-up and quiet
|
||||
2 warm lead gone quiet (no contact in >= warm_days)
|
||||
"""
|
||||
own = {(a or "").lower() for a in (our_addresses or [])}
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM fundraising_investors").fetchall()
|
||||
except Exception:
|
||||
return []
|
||||
items = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
inv_id, name = d.get("id"), d.get("investor_name")
|
||||
if not inv_id:
|
||||
continue
|
||||
gv = d.get("graveyard")
|
||||
if gv and str(gv).strip().lower() not in ("", "0", "false", "no"):
|
||||
continue # buried leads are out of scope
|
||||
try:
|
||||
erows = conn.execute(
|
||||
"SELECT e.from_email, 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 50", (inv_id,)).fetchall()
|
||||
except Exception:
|
||||
erows = []
|
||||
if not erows:
|
||||
continue # no email history -> nothing to base a nudge on
|
||||
last = erows[0]
|
||||
days = _days_between(last["sent_at"], now_iso)
|
||||
if days is None:
|
||||
continue
|
||||
inbound_last = (last["from_email"] or "").lower() not in own # they emailed last
|
||||
ff = d.get("follow_up")
|
||||
flagged = bool(ff) and str(ff).strip().lower() not in ("", "0", "false", "no")
|
||||
|
||||
reason, tier, suggested = None, None, "follow_up"
|
||||
if inbound_last and days >= 3:
|
||||
reason, tier, suggested = f"You owe a reply — they emailed {days} days ago", 0, "follow_up"
|
||||
elif flagged and days >= 14:
|
||||
reason, tier, suggested = f"Flagged for follow-up, quiet {days} days", 1, "follow_up"
|
||||
elif days >= warm_days and len(erows) >= 2:
|
||||
reason, tier, suggested = f"No contact in {days} days", 2, "nurture"
|
||||
if reason is None:
|
||||
continue
|
||||
if flagged and tier != 1:
|
||||
reason += " · flagged"
|
||||
items.append({"investor_id": inv_id, "name": name, "reason": reason,
|
||||
"days_since": days, "suggested_type": suggested, "tier": tier})
|
||||
items.sort(key=lambda x: (x["tier"], -x["days_since"]))
|
||||
return items[:limit]
|
||||
|
||||
|
||||
def _context(conn, investor_id):
|
||||
"""Assemble the recipient's context. Structured so the model replies to the ACTIVE
|
||||
conversation (the most recent email thread) while still having earlier emails as
|
||||
background. 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"]
|
||||
header = [f"Investor: {name}"]
|
||||
notes = (row["notes"] or "").strip()
|
||||
if notes:
|
||||
header.append("CRM notes:\n" + notes)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT e.subject, e.body_text, e.snippet, e.sent_at, e.thread_id 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 20", (investor_id,)).fetchall()
|
||||
except Exception:
|
||||
rows = [] # email tables may be absent / not yet captured
|
||||
active, background = [], []
|
||||
if rows:
|
||||
active_thread = rows[0]["thread_id"]
|
||||
for em in rows:
|
||||
body = (em["body_text"] or em["snippet"] or "")[:1500].strip()
|
||||
block = f"({(em['sent_at'] or '')[:10]}) {em['subject'] or '(no subject)'}\n{body}"
|
||||
in_active = active_thread is not None and em["thread_id"] == active_thread
|
||||
(active if in_active else background).append(block)
|
||||
sections = ["\n".join(header)]
|
||||
if active:
|
||||
sections.append("=== Active conversation (the most recent thread — this is what you are replying to) ===\n"
|
||||
+ "\n\n".join(reversed(active[:6])))
|
||||
if background:
|
||||
sections.append("=== Earlier emails (background only, not the active thread) ===\n"
|
||||
+ "\n\n".join(background[:4]))
|
||||
return name, "\n\n".join(sections)
|
||||
|
||||
|
||||
# Keyword cues used to pick the sender's prior emails of the SAME PURPOSE as the draft
|
||||
# (so the voice few-shot matches what they're writing, not just whatever is most recent).
|
||||
PURPOSE_PATTERNS = {
|
||||
"intro": ["introduc", "nice to meet", "reaching out", "wanted to connect", "by way of introduction", "e-meet"],
|
||||
"follow_up": ["follow up", "following up", "circle back", "circling back", "checking in",
|
||||
"wanted to revisit", "any thoughts", "wanted to follow", "touching base"],
|
||||
"fund_update": ["update", "progress", "quarter", "deployed", "portfolio", "milestone", "closing", "fund iii"],
|
||||
"meeting_follow_up": ["great to meet", "great speaking", "thanks for the call", "thanks for your time",
|
||||
"after our", "following our", "enjoyed our", "great to connect", "great chatting"],
|
||||
"nurture": ["checking in", "hope you", "thinking of you", "stay in touch", "wanted to share", "thought you"],
|
||||
}
|
||||
|
||||
|
||||
def _voice_examples(conn, sender_email, outreach_type=None, limit=8):
|
||||
"""The sender's OWN sent LP emails OF THE SAME PURPOSE — used as voice few-shot AND
|
||||
surfaced for transparency (no black box). Larger sample, purpose-weighted (not just
|
||||
recent). Returns (blocks_for_model, meta_for_ui); meta is the sender's own emails."""
|
||||
if not sender_email:
|
||||
return [], []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT subject, body_text, snippet, sent_at, to_emails_json FROM emails "
|
||||
"WHERE LOWER(from_email) = LOWER(?) AND is_matched = 1 "
|
||||
"AND body_text IS NOT NULL AND TRIM(body_text) <> '' "
|
||||
"ORDER BY sent_at DESC LIMIT 80", (sender_email,)).fetchall()
|
||||
except Exception:
|
||||
return [], []
|
||||
pats = PURPOSE_PATTERNS.get(outreach_type or "", [])
|
||||
scored = []
|
||||
for idx, r in enumerate(rows):
|
||||
text = ((r["subject"] or "") + " " + (r["body_text"] or r["snippet"] or "")).lower()
|
||||
score = sum(1 for p in pats if p in text)
|
||||
scored.append((score, -idx, r)) # purpose match first, then more recent
|
||||
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
blocks, meta = [], []
|
||||
for score, _neg_idx, r in scored[:limit]:
|
||||
body = (r["body_text"] or r["snippet"] or "")[:900].strip()
|
||||
if not body:
|
||||
continue
|
||||
blocks.append(f"Example — {r['subject'] or '(no subject)'}\n{body}")
|
||||
to = ""
|
||||
try:
|
||||
arr = json.loads(r["to_emails_json"] or "[]")
|
||||
if arr:
|
||||
to = arr[0].get("email") if isinstance(arr[0], dict) else arr[0]
|
||||
except Exception:
|
||||
to = ""
|
||||
meta.append({"subject": r["subject"] or "(no subject)", "date": (r["sent_at"] or "")[:10],
|
||||
"to": to, "on_topic": score > 0})
|
||||
return blocks, meta
|
||||
|
||||
|
||||
def _draft_with_claude(aa, thesis, type_desc, deident_context, deident_voice, guidance):
|
||||
voice_block = ""
|
||||
if deident_voice:
|
||||
voice_block = ("\n\nHere are examples of how THIS sender actually writes (de-identified). Match their "
|
||||
"voice, tone, sentence rhythm, openers, and sign-off — not just the rules above:\n\n"
|
||||
+ "\n\n---\n\n".join(deident_voice))
|
||||
system = (
|
||||
"You are Ten31's outreach copilot. Draft ONE ready-to-send LP outreach email in the SENDER's voice. "
|
||||
f"VOICE RULES (follow exactly): {aa.VOICE}" + voice_block + "\n\n"
|
||||
"Ten31 invests in critical infrastructure across bitcoin, AI, energy, and freedom technologies. "
|
||||
"The spine: fiat is being debased while AI drives the marginal cost of the reproducible toward "
|
||||
"zero, so durable value accrues to what stays provably scarce, and the monetary premium accrues "
|
||||
"to bitcoin as the apex non-debasable reserve asset. AI is the abundance engine and bitcoin is "
|
||||
"the scarcity anchor. Ten31 owns the scarce links of that one supply chain. This is an "
|
||||
"asset-value and capital-flow conviction, not a claim that the world transacts or settles in "
|
||||
"bitcoin. 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. Reply to the ACTIVE conversation; use the "
|
||||
"earlier emails only as background. Output a subject line, then the email body. 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, sender_email=None):
|
||||
"""Draft tailored outreach for one investor, in the SENDER's voice (few-shot from
|
||||
their own prior emails). 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"])
|
||||
voice_blocks, voice_meta = _voice_examples(conn, sender_email, outreach_type)
|
||||
|
||||
# 1) Scrub the sender's voice examples + the recipient context TOGETHER (shared token
|
||||
# space). Nothing reaches Claude in the clear; the voice examples are reference only.
|
||||
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(list(voice_blocks) + [context], bucket=False, conn=conn)
|
||||
except Exception as exc:
|
||||
return {"status": "scrub_unavailable", "reason": str(exc)}
|
||||
items = scrubbed["items"]
|
||||
deident_voice, deident_target = items[:-1], items[-1]
|
||||
handle = scrubbed["handle"]
|
||||
|
||||
# 2) Claude drafts over the de-identified context + voice + (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_target, deident_voice, 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", {}), "voice_examples": voice_meta}
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/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,
|
||||
from_email TEXT, to_emails_json TEXT, thread_id 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,thread_id,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", "t1"),
|
||||
("e2", "Intro", "Good to meet you at the dinner.", "2026-05-01T10:00:00", "t0"),
|
||||
("e3", "Spam", "ignore me", "2026-04-01T10:00:00", "t9"), # 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, "active-thread email present")
|
||||
check("Good to meet you" in ctx, "earlier email present as background")
|
||||
check("ignore me" not in ctx, "excludes email not linked to this investor")
|
||||
check("Active conversation" in ctx and "Earlier emails" in ctx
|
||||
and ctx.index("lock-up terms") < ctx.index("Good to meet you"),
|
||||
"active thread is separated from background, active first")
|
||||
|
||||
# voice examples: the sender's own sent emails (few-shot + transparency)
|
||||
c.execute("INSERT INTO emails (id,subject,body_text,sent_at,from_email,to_emails_json,thread_id,is_matched) "
|
||||
"VALUES ('v1','My note','Hi there, quick update on the fund. Best, Grant',"
|
||||
"'2026-06-01T10:00:00','grant@ten31.xyz','[{\"email\":\"lp@x.example\"}]','tv',1)")
|
||||
c.commit()
|
||||
blocks, meta = oa._voice_examples(c, "grant@ten31.xyz")
|
||||
check(len(blocks) == 1 and "quick update on the fund" in blocks[0], "voice example pulls the sender's own email")
|
||||
check(len(meta) == 1 and meta[0]["subject"] == "My note" and meta[0]["to"] == "lp@x.example",
|
||||
"voice meta carries subject + recipient for transparency")
|
||||
check(oa._voice_examples(c, None) == ([], []), "no sender -> no voice examples")
|
||||
|
||||
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")
|
||||
|
||||
# ── follow-up radar ──
|
||||
rc = sqlite3.connect(os.path.join(tempfile.mkdtemp(), "radar.db"))
|
||||
rc.row_factory = sqlite3.Row
|
||||
rc.executescript("""
|
||||
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, follow_up TEXT, graveyard TEXT);
|
||||
CREATE TABLE emails (id TEXT PRIMARY KEY, from_email TEXT, sent_at TEXT, is_matched INT);
|
||||
CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT);
|
||||
""")
|
||||
rc.executemany("INSERT INTO fundraising_investors (id,investor_name,follow_up,graveyard) VALUES (?,?,?,?)", [
|
||||
("owe", "Owe Reply LP", None, None), # they emailed last, 5d ago -> tier 0
|
||||
("warm", "Warm Quiet LP", None, None), # we emailed last, 60d ago -> tier 2
|
||||
("fresh", "Fresh LP", None, None), # we emailed 4d ago -> not surfaced
|
||||
("buried", "Buried LP", None, "1"), # graveyard -> excluded
|
||||
])
|
||||
OURS = "grant@ten31.xyz"
|
||||
em = [
|
||||
("o1", "lp@owe.example", "2026-06-04T10:00:00", "owe"), # inbound, 5 days before 06-09
|
||||
("w1", OURS, "2026-04-10T10:00:00", "warm"), # outbound, ~60 days
|
||||
("w0", "lp@warm.example", "2026-04-01T10:00:00", "warm"), # 2nd email for history
|
||||
("f1", OURS, "2026-06-05T10:00:00", "fresh"), # outbound, 4 days -> too recent
|
||||
("b1", "lp@buried.example", "2026-01-01T10:00:00", "buried"),
|
||||
]
|
||||
for eid, frm, sent, inv in em:
|
||||
rc.execute("INSERT INTO emails (id,from_email,sent_at,is_matched) VALUES (?,?,?,1)", (eid, frm, sent))
|
||||
rc.execute("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id) VALUES (?,?,?)", (eid + "l", eid, inv))
|
||||
rc.commit()
|
||||
radar = oa.follow_up_radar(rc, [OURS], "2026-06-09T00:00:00", warm_days=45)
|
||||
names = [x["name"] for x in radar]
|
||||
check("Owe Reply LP" in names and "Warm Quiet LP" in names, f"surfaces owe-reply + warm-quiet (got {names})")
|
||||
check("Fresh LP" not in names, "recent contact not surfaced")
|
||||
check("Buried LP" not in names, "graveyard excluded")
|
||||
check(radar[0]["name"] == "Owe Reply LP" and radar[0]["tier"] == 0, "owe-a-reply ranked first (tier 0)")
|
||||
owe = next(x for x in radar if x["name"] == "Owe Reply LP")
|
||||
check("owe a reply" in owe["reason"] and owe["suggested_type"] == "follow_up", "owe-reply reason + suggested type")
|
||||
warm = next(x for x in radar if x["name"] == "Warm Quiet LP")
|
||||
check(warm["tier"] == 2 and warm["suggested_type"] == "nurture", "warm-quiet is tier 2, suggests nurture")
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -485,6 +487,22 @@ def init_db():
|
||||
except Exception as _e:
|
||||
print(f"[thesis] positioning framings warning: {_e}")
|
||||
|
||||
# One-time: stage the v2.0 reserve-asset spine (signal-engine workstream) as candidates.
|
||||
try:
|
||||
from thesis_seed import ensure_thesis_v2_candidate as _ensure_thesis_v2_candidate
|
||||
_ensure_thesis_v2_candidate(conn)
|
||||
except Exception as _e:
|
||||
print(f"[thesis] v2 candidate warning: {_e}")
|
||||
|
||||
# One-time: promote the v2.0 spine to the WORKING (approved) thesis and soft-retire the old
|
||||
# settlement throughline + Pillar 1, so the live agents stop emitting the dead spine. Node-level
|
||||
# only; the canonical thesis_version freeze stays the partners' dual-approval action (guardrail #4).
|
||||
try:
|
||||
from thesis_seed import ensure_thesis_v2_promoted as _ensure_thesis_v2_promoted
|
||||
_ensure_thesis_v2_promoted(conn)
|
||||
except Exception as _e:
|
||||
print(f"[thesis] v2 promote warning: {_e}")
|
||||
|
||||
conn.close()
|
||||
print(f"Database initialized at {DB_PATH}")
|
||||
|
||||
@@ -1804,6 +1822,10 @@ 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)
|
||||
if path == '/api/outreach/radar':
|
||||
return self.handle_outreach_radar(user)
|
||||
|
||||
# Users
|
||||
if path == '/api/users':
|
||||
@@ -1907,6 +1929,10 @@ 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 path == '/api/outreach/gmail-draft':
|
||||
return self.handle_outreach_gmail_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 +3931,99 @@ 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_radar(self, user):
|
||||
"""Deterministic 'who needs attention' scan (reasons are checkable, not LLM guesses)."""
|
||||
if _outreach_agent is None:
|
||||
return self.send_error_json("Outreach agent unavailable", 503)
|
||||
conn = get_db()
|
||||
try:
|
||||
try:
|
||||
own = [r[0] for r in conn.execute("SELECT email_address FROM email_accounts")]
|
||||
except Exception:
|
||||
own = []
|
||||
items = _outreach_agent.follow_up_radar(conn, own, now())
|
||||
return self.send_json({"items": items})
|
||||
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:
|
||||
sender_email = None
|
||||
try:
|
||||
r = conn.execute("SELECT email FROM users WHERE id=?", (user.get('user_id'),)).fetchone()
|
||||
sender_email = r[0] if r else None
|
||||
except Exception:
|
||||
pass
|
||||
res = _outreach_agent.draft_outreach(conn, inv, body.get('outreach_type', 'follow_up'),
|
||||
body.get('guidance', '') or '', DB_PATH, sender_email=sender_email)
|
||||
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})
|
||||
|
||||
def handle_outreach_gmail_draft(self, user, body):
|
||||
"""Create a Gmail DRAFT from an approved outreach draft (in-thread reply when there
|
||||
is an active thread). Never sends — the human sends from Gmail (guardrails #4, #6)."""
|
||||
body = body or {}
|
||||
inv = body.get('investor_id')
|
||||
text = body.get('draft') or ''
|
||||
if not inv or not text.strip():
|
||||
return self.send_error_json("investor_id and draft required", 400)
|
||||
try:
|
||||
from email_integration import compose as _compose
|
||||
except Exception:
|
||||
return self.send_error_json("Gmail compose unavailable", 503)
|
||||
conn = get_db()
|
||||
try:
|
||||
sender_email = None
|
||||
try:
|
||||
r = conn.execute("SELECT email FROM users WHERE id=?", (user.get('user_id'),)).fetchone()
|
||||
sender_email = r[0] if r else None
|
||||
except Exception:
|
||||
pass
|
||||
res = _compose.create_outreach_draft(conn, sender_email, inv, text)
|
||||
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.gmail_draft_created",
|
||||
"fundraising_investor", inv, json.dumps({"status": res.get('status')}), "crm_ui", now()))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
|
||||
+243
-13
@@ -60,11 +60,16 @@ def _node(conn, line_id, parent_id, node_type, ordn, title, body, status="draft"
|
||||
|
||||
THROUGHLINE = (
|
||||
"Bitcoin, AI, and energy are three of the largest growth markets of the next decade, "
|
||||
"and they depend on the same scarce resources: cheap energy and computing power. We "
|
||||
"believe that energy, compute, and AI infrastructure will settle on money that is hard "
|
||||
"to produce. That is not the case today, and connecting these markets to bitcoin is the "
|
||||
"part of the thesis that very few others are making, even as broader crypto tries to "
|
||||
"attach itself to AI and energy. Ten31 invests in that infrastructure with strong conviction."
|
||||
"and the scarce links across them (cheap energy, compute, and the non-debasable reserve "
|
||||
"asset) capture disproportionate value as the megatrend runs. Fiat is being debased "
|
||||
"through structural deficits financed by monetary expansion, and AI is collapsing the "
|
||||
"marginal cost of anything reproducible toward zero. As money debases and AI commoditizes "
|
||||
"the reproducible, durable value accrues to the scarce side of this one supply chain, and "
|
||||
"the monetary premium accrues to bitcoin as the apex fixed-supply, non-debasable, "
|
||||
"verifiable reserve asset. AI is the abundance engine and bitcoin is the scarcity anchor, "
|
||||
"two faces of one megatrend. This is an asset-value and capital-flow conviction about "
|
||||
"where value accrues, and it is the specific connection very few others are making. Ten31 "
|
||||
"invests in that infrastructure with strong conviction."
|
||||
)
|
||||
|
||||
OPTION_A = (
|
||||
@@ -78,11 +83,14 @@ OPTION_B = (
|
||||
|
||||
PILLAR_1 = (
|
||||
"Every one of these markets is bottlenecked on something scarce. AI and bitcoin both "
|
||||
"compete for cheap energy and compute. And we believe energy, compute, and AI "
|
||||
"infrastructure will increasingly settle on money that is hard to produce, which points "
|
||||
"directly at bitcoin. The companies that own and supply the scarce side of that equation "
|
||||
"capture the value as demand grows. That is where we invest. (The bitcoin connection is a "
|
||||
"forward-looking conviction, not a description of today. That gap is the opportunity.)"
|
||||
"compete for cheap energy and compute, and each seam pairs a scarce input with an "
|
||||
"abundant one: energy with compute, debasement with bitcoin as the non-debasable reserve, "
|
||||
"AI with sovereign data ownership. As money debases and AI drives the reproducible toward "
|
||||
"zero cost, durable value accrues to the provably scarce side of that supply chain, and "
|
||||
"the monetary premium accrues to bitcoin as a reserve asset. The companies that own and "
|
||||
"supply the scarce side capture the value as demand grows. That is where we invest. (The "
|
||||
"value-accrual-to-bitcoin connection is forward-looking conviction about where value "
|
||||
"accrues, and that gap between today and that future is the opportunity.)"
|
||||
)
|
||||
PILLAR_2 = (
|
||||
"We invest in foundational infrastructure with real revenue: the companies that generate "
|
||||
@@ -139,9 +147,11 @@ SEGMENTS = [
|
||||
"Don't talk down to them; it is the same thesis, just an accessible entry point."),
|
||||
("ai_energy_operator", "AI & energy operators",
|
||||
"Operators in AI and energy who are not yet focused on bitcoin.",
|
||||
"You may not be focused on bitcoin today, and that is exactly the point. We believe bitcoin "
|
||||
"becomes a larger component of energy and compute over time, and most operators in your "
|
||||
"space are not yet positioned for it. We are, and we invest across the stack that connects them.",
|
||||
"You may not be focused on bitcoin today, and that is exactly the point. AI and bitcoin "
|
||||
"both compete for the same scarce input, cheap and flexible power, and most operators in "
|
||||
"your space are not yet positioned for that convergence. We are. We invest across the "
|
||||
"energy-to-compute stack that connects them, with mining-native fluency (interruptible "
|
||||
"load, behind-the-meter, stranded-gas-to-power) a generalist lacks.",
|
||||
"Don't assume they're bitcoin-focused and don't preach; connect bitcoin as a growing "
|
||||
"component of their world over time."),
|
||||
]
|
||||
@@ -291,3 +301,223 @@ def ensure_positioning_framings(conn):
|
||||
{"count": len(inserted), "source": "architect-pass-2026-06-05", "node_ids": inserted})
|
||||
conn.commit()
|
||||
print(f"[thesis] seeded {len(inserted)} Architect positioning framings into the Workshop")
|
||||
|
||||
|
||||
# ── v2.0 spine (signal-engine workstream, 2026-06-09) ─────────────────────────
|
||||
# A thesis correction from a parallel "signal-engine" workstream Grant runs: the spine
|
||||
# is NOT "infrastructure settles on bitcoin" (settlement/payments — Strike's payments
|
||||
# thesis died in backtest) but bitcoin as the APEX NON-DEBASABLE RESERVE ASSET, with
|
||||
# debasement as the forcing function and AI as the abundance engine. Staged as CANDIDATE
|
||||
# content (never canonical — guardrail #4); provenance + the "unratified, exposure
|
||||
# figures unconfirmed by Grant" caveat are stated in the section node. The partners
|
||||
# ratify, modify, or reject it at their working session.
|
||||
THESIS_V2 = {
|
||||
"provenance": ("CANDIDATE, from the parallel signal-engine workstream (2026-06-09), NOT yet ratified by "
|
||||
"the partners and NOT in any canonical thesis doc. It corrects the v5 spine from a "
|
||||
"settlement/payments claim to a reserve-asset / capital-flow conviction. Conviction and "
|
||||
"exposure levels are a working read of Grant's words; Grant confirms before anything is promoted."),
|
||||
"root": ("Fiat is being debased through structural deficits financed by monetary expansion. At the same "
|
||||
"time, AI is collapsing the marginal cost of anything reproducible toward zero. When the "
|
||||
"reproducible becomes nearly free, durable economic value migrates to what remains provably "
|
||||
"scarce and verifiable. Bitcoin is the apex form of that: a fixed-supply, non-debasable, "
|
||||
"verifiable reserve asset. AI is the abundance engine; bitcoin is the scarcity anchor. They "
|
||||
"are two faces of one megatrend."),
|
||||
"throughline": ("Bitcoin, AI, and energy are three of the largest growth markets of the next decade, and "
|
||||
"the scarce links across them (cheap energy, compute, and the non-debasable reserve asset) "
|
||||
"capture disproportionate value as the megatrend runs. Ten31's differentiated conviction is "
|
||||
"the specific connection: as money debases and AI commoditizes the reproducible, value "
|
||||
"accrues to the scarce side of this one supply chain, and the monetary premium accrues to "
|
||||
"bitcoin as the apex non-debasable reserve asset. This is the precise claim that the scarce "
|
||||
"inputs of these markets win and the monetary premium accrues to hard money."),
|
||||
"decomposition": ("Verifiable today: power, compute, and AI infrastructure draw on the same scarce inputs "
|
||||
"(the bottleneck is physically co-located); fiat is being debased; bitcoin is provably "
|
||||
"scarce (21M cap); AI is collapsing the marginal cost of reproducible output. Contrarian "
|
||||
"and forward-looking (the unproven leg Ten31 uniquely owns): as the reproducible goes to "
|
||||
"zero cost and money debases, durable value accrues to the provably scarce, and bitcoin "
|
||||
"appreciates as the premier non-debasable reserve asset. This is an asset-value and "
|
||||
"capital-flow claim about where the monetary premium accrues. Lead with the "
|
||||
"verifiable co-location; earn the value-accrual conviction; never overclaim the rail."),
|
||||
"seams": [
|
||||
("Seam 1: Energy and Compute",
|
||||
"AI and bitcoin both compete for the same scarce input: cheap, firm, flexible power. The companies "
|
||||
"that own and supply the scarce side capture value as demand grows. Mining-native fluency "
|
||||
"(interruptible load, behind-the-meter, stranded-gas-to-power) is a real underwriting edge a "
|
||||
"generalist lacks."),
|
||||
("Seam 2: Debasement and Bitcoin",
|
||||
"As money debases, bitcoin is the non-debasable reserve, and the investable layer is the "
|
||||
"infrastructure to access, hold, leverage, and utilize it: custody, exchange, and especially bitcoin "
|
||||
"as pristine collateral for credit. As bitcoin "
|
||||
"credit products mature, holders borrow rather than sell, shrinking marginal supply, so scarcity "
|
||||
"amplifies."),
|
||||
("Seam 3: AI and Data-Ownership",
|
||||
"As AI commoditizes baseline competence, profit on undifferentiated output erodes toward zero; "
|
||||
"durable margin accrues to those who own and protect their proprietary data and judgment. The "
|
||||
"investable layer is sovereign data and confidential inference: own your stack, own your inference. "
|
||||
"This is Ten31's published coherence conviction."),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def ensure_thesis_v2_candidate(conn):
|
||||
"""Stage the v2.0 reserve-asset spine as CANDIDATE nodes under the core line so the
|
||||
partners can review it in the Workshop. One-time (interaction_log sentinel),
|
||||
additive, non-canonical (guardrail #4)."""
|
||||
try:
|
||||
already = conn.execute(
|
||||
"SELECT 1 FROM interaction_log WHERE action='thesis.v2_candidate_seeded' LIMIT 1").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
if already:
|
||||
return
|
||||
row = conn.execute("SELECT id FROM thesis_lines WHERE line_key='core' AND deleted_at IS NULL").fetchone()
|
||||
if not row:
|
||||
return
|
||||
core = row[0]
|
||||
root = conn.execute("SELECT id FROM thesis_nodes WHERE line_id=? AND node_type='thesis_root' "
|
||||
"AND deleted_at IS NULL ORDER BY ord LIMIT 1", (core,)).fetchone()
|
||||
if not root:
|
||||
return
|
||||
sec = _node(conn, core, root[0], "section", 6.5,
|
||||
"v2.0 spine — reserve asset (CANDIDATE · signal-engine workstream · unratified)",
|
||||
THESIS_V2["provenance"], status="candidate")
|
||||
_node(conn, core, sec, "claim", 1, "Root / forcing function (v2.0)", THESIS_V2["root"], status="candidate")
|
||||
_node(conn, core, sec, "throughline", 2, "Throughline (v2.0)", THESIS_V2["throughline"], status="candidate")
|
||||
_node(conn, core, sec, "claim", 3, "Verifiable vs contrarian decomposition (v2.0)",
|
||||
THESIS_V2["decomposition"], status="candidate")
|
||||
for i, (title, body) in enumerate(THESIS_V2["seams"], start=4):
|
||||
_node(conn, core, sec, "claim", float(i), title, body, status="candidate")
|
||||
_log(conn, "thesis.v2_candidate_seeded", core, {"source": "signal-engine-v2.0-2026-06-09", "section": sec})
|
||||
conn.commit()
|
||||
print("[thesis] staged v2.0 reserve-asset spine as candidate nodes in the Workshop")
|
||||
|
||||
|
||||
def ensure_thesis_v2_promoted(conn):
|
||||
"""Make the v2.0 reserve-asset spine the WORKING (approved) spine so the live Architect /
|
||||
outreach prompts (which flatten the whole node tree) stop emitting the dead settlement
|
||||
framing. DEPLOYMENT-STATE-INVARIANT: it targets nodes by structure (the throughline directly
|
||||
under the core thesis_root; the Pillar-1 claim by its stable title; the v2.0 section's
|
||||
children by ord), never by transient body text, so it produces the SAME clean result whether
|
||||
the box was seeded with the old settlement constants (pre-existing box) or the new reserve
|
||||
constants (fresh box).
|
||||
|
||||
What it does, all reversibly:
|
||||
* Rewrites the core throughline body to the reserve-asset THROUGHLINE (this is the single
|
||||
canonical throughline; the v2.0 section's redundant throughline child is soft-retired).
|
||||
* Re-grounds Pillar 1 in place to the reserve-asset PILLAR_1 (the three pillars are KEPT and
|
||||
re-grounded per the v2.0 handoff section 5, not dropped).
|
||||
* Refreshes the v2.0 section's root / decomposition / seam children from the current (cleaned)
|
||||
THESIS_V2 constant, so a box that staged the section earlier with stale text is corrected,
|
||||
and promotes them to 'approved'.
|
||||
|
||||
NODE-LEVEL ONLY. This NEVER sets a thesis_version to 'canonical' (guardrail #4): freezing v2.0
|
||||
as the canonical version stays the partners' dual-approval action. The v2.0 spine is still an
|
||||
unratified draft from the parallel signal-engine workstream.
|
||||
|
||||
Idempotent (interaction_log sentinel). Reversible and non-destructive (guardrail #3): nothing
|
||||
is hard-deleted; the redundant throughline child is SOFT-retired (deleted_at), and the prior
|
||||
body/title/status/deleted_at of EVERY touched row is captured so revert restores it exactly."""
|
||||
try:
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM interaction_log WHERE action='thesis.v2_spine_promoted' LIMIT 1").fetchone():
|
||||
return
|
||||
except sqlite3.OperationalError:
|
||||
return # thesis / interaction_log tables not present yet
|
||||
core_row = conn.execute(
|
||||
"SELECT id FROM thesis_lines WHERE line_key='core' AND deleted_at IS NULL").fetchone()
|
||||
if not core_row:
|
||||
return # core line not seeded yet
|
||||
core = core_row[0]
|
||||
root_row = conn.execute(
|
||||
"SELECT id FROM thesis_nodes WHERE line_id=? AND node_type='thesis_root' AND deleted_at IS NULL "
|
||||
"ORDER BY ord LIMIT 1", (core,)).fetchone()
|
||||
sec_row = conn.execute(
|
||||
"SELECT id FROM thesis_nodes WHERE line_id=? AND node_type='section' "
|
||||
"AND title LIKE 'v2.0 spine%' AND deleted_at IS NULL ORDER BY ord LIMIT 1", (core,)).fetchone()
|
||||
if not root_row or not sec_row:
|
||||
return # v2.0 candidate not staged yet (ensure_thesis_v2_candidate runs first) -> no-op
|
||||
root_id, sec_id = root_row[0], sec_row[0]
|
||||
now = _now()
|
||||
touched = [] # prior {id, body, title, status, deleted_at} of every row we change, for exact revert
|
||||
|
||||
def capture(nid):
|
||||
r = conn.execute("SELECT id, body, title, status, deleted_at FROM thesis_nodes WHERE id=?", (nid,)).fetchone()
|
||||
if r:
|
||||
touched.append({"id": r[0], "body": r[1], "title": r[2], "status": r[3], "deleted_at": r[4]})
|
||||
return r
|
||||
|
||||
def set_node(nid, body=None, title=None, status=None, deleted_at="keep"):
|
||||
sets, vals = ["updated_at=?"], [now]
|
||||
if body is not None:
|
||||
sets.append("body=?"); vals.append(body)
|
||||
if title is not None:
|
||||
sets.append("title=?"); vals.append(title)
|
||||
if status is not None:
|
||||
sets.append("status=?"); vals.append(status)
|
||||
if deleted_at != "keep":
|
||||
sets.append("deleted_at=?"); vals.append(deleted_at)
|
||||
vals.append(nid)
|
||||
conn.execute(f"UPDATE thesis_nodes SET {', '.join(sets)} WHERE id=?", vals)
|
||||
|
||||
# 1) Core throughline (directly under root): rewrite to the reserve-asset throughline, approved.
|
||||
th = conn.execute(
|
||||
"SELECT id FROM thesis_nodes WHERE line_id=? AND parent_id=? AND node_type='throughline' "
|
||||
"AND deleted_at IS NULL ORDER BY ord LIMIT 1", (core, root_id)).fetchone()
|
||||
if th:
|
||||
capture(th[0]); set_node(th[0], body=THROUGHLINE, status="approved")
|
||||
# 2) Pillar 1 (KEPT, re-grounded to reserve): rewrite body in place, approved.
|
||||
p1 = conn.execute(
|
||||
"SELECT id FROM thesis_nodes WHERE line_id=? AND node_type='claim' "
|
||||
"AND title LIKE '1. Scarcity is the whole opportunity%' AND deleted_at IS NULL ORDER BY ord LIMIT 1",
|
||||
(core,)).fetchone()
|
||||
if p1:
|
||||
capture(p1[0]); set_node(p1[0], body=PILLAR_1, status="approved")
|
||||
# 3) v2.0 section children: refresh from the cleaned THESIS_V2 constant + promote; retire the
|
||||
# redundant throughline child (the core throughline above carries the canonical throughline).
|
||||
seams = THESIS_V2["seams"]
|
||||
for r in conn.execute(
|
||||
"SELECT id, node_type, ord FROM thesis_nodes WHERE parent_id=? AND deleted_at IS NULL ORDER BY ord",
|
||||
(sec_id,)).fetchall():
|
||||
nid, ntype, ordn = r[0], r[1], int(round(r[2]))
|
||||
capture(nid)
|
||||
if ntype == "throughline":
|
||||
set_node(nid, status="retired", deleted_at=now) # redundant with the core throughline
|
||||
elif ordn == 1:
|
||||
set_node(nid, body=THESIS_V2["root"], status="approved")
|
||||
elif ordn == 3:
|
||||
set_node(nid, body=THESIS_V2["decomposition"], status="approved")
|
||||
elif ordn >= 4 and (ordn - 4) < len(seams):
|
||||
t, b = seams[ordn - 4]
|
||||
set_node(nid, title=t, body=b, status="approved")
|
||||
else:
|
||||
set_node(nid, status="approved")
|
||||
capture(sec_id); set_node(sec_id, body=THESIS_V2["provenance"], status="approved")
|
||||
|
||||
_log(conn, "thesis.v2_spine_promoted", core,
|
||||
{"touched": touched, "section_id": sec_id,
|
||||
"note": "reserve-asset spine made the working approved spine at NODE level only; canonical "
|
||||
"promotion remains the human dual-approval gate; v2.0 spine unratified pending the "
|
||||
"Grant + Jonathan session"})
|
||||
conn.commit()
|
||||
print("[thesis] promoted v2.0 reserve-asset spine to the working approved spine (deployment-state-invariant)")
|
||||
|
||||
|
||||
def revert_thesis_v2_promotion(conn):
|
||||
"""Exact inverse of ensure_thesis_v2_promoted. Restores the captured prior body / title /
|
||||
status / deleted_at of every touched node (the down is exact with respect to logical state;
|
||||
only updated_at is re-stamped, which is standard), and clears the sentinel so promote can re-run."""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM interaction_log WHERE action='thesis.v2_spine_promoted' "
|
||||
"ORDER BY ts DESC LIMIT 1").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
if not row:
|
||||
return # never promoted
|
||||
p = json.loads(row[0])
|
||||
now = _now()
|
||||
for t in p.get("touched", []):
|
||||
conn.execute("UPDATE thesis_nodes SET body=?, title=?, status=?, deleted_at=?, updated_at=? WHERE id=?",
|
||||
(t.get("body"), t.get("title"), t.get("status"), t.get("deleted_at"), now, t.get("id")))
|
||||
conn.execute("DELETE FROM interaction_log WHERE action='thesis.v2_spine_promoted'")
|
||||
conn.commit()
|
||||
print("[thesis] reverted v2.0 spine promotion; restored prior node bodies/titles/status exactly")
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Grant: *"we are gravitating towards what we think the key message is, but we hav
|
||||
## What the partners must supply (the content the substrate can't invent)
|
||||
|
||||
The Architect *sharpens an existing thesis; it does not author one from nothing.* These are inputs, co-authored in the first Architect sessions:
|
||||
- [ ] **Thesis seed (v1):** the current-best throughline (scarcity / critical-infrastructure tying bitcoin ↔ AI infrastructure / energy / freedom tech) broken into **3–5 pillars** and a first set of testable **claims**.
|
||||
- [ ] **Thesis seed (v2.0 reserve-asset spine):** the current-best throughline (as fiat debases and AI commoditizes the reproducible, value accrues to the scarce side of one supply chain and the monetary premium accrues to bitcoin as the non-debasable reserve asset — an asset-value/capital-flow claim, not settlement/payments) structured on **three seams** (Energy↔Compute, Debasement↔Bitcoin, AI↔Data-Ownership) and a first set of testable **claims**.
|
||||
- [ ] **LP segments** (build-plan open decision #4): confirm/define the distinct audiences (proposed starter set: family office, institution, bitcoin-native HNWI, energy player) and, per segment, *what they need to hear* and *what to avoid saying*.
|
||||
- [ ] **Voice:** tone/diction, "this is us / this is not us" before-after examples, sacred phrases, words we never use.
|
||||
- [ ] **Approval policy:** who may promote a thesis version to canonical (any admin? Grant specifically? dual partner sign-off?).
|
||||
|
||||
@@ -26,7 +26,7 @@ Build **six agents** — five workers plus a lightweight orchestrator — on the
|
||||
|---|---|---|---|---|
|
||||
| **Scout** | Watches sources (X/nostr, filings, treasury announcements, conference rosters, podcast networks); flags trigger events; populates the pipeline. | Continuous / scheduled | Local (triage) + Claude (judgment calls) | None (internal only) |
|
||||
| **Analyst** | Builds LP dossiers, enriches records, maps shortest warm-intro path through the team's network. | On-demand + triggered | Claude (synthesis); local for RAG/embeddings | None (internal only) |
|
||||
| **Architect** | **Thesis articulation.** Owns and refines the canonical messaging — the scarcity / critical-infrastructure throughline tying bitcoin to AI infrastructure. The copilot partners sit with to sharpen the narrative. Output = a living "messaging source of truth." | On-demand, collaborative | Claude | Partner sign-off on canonical thesis |
|
||||
| **Architect** | **Thesis articulation.** Owns and refines the canonical messaging — the reserve-asset throughline: as fiat debases and AI commoditizes the reproducible, value accrues to the scarce side of one supply chain (energy, compute, and bitcoin as the non-debasable reserve asset), structured on three seams (Energy↔Compute, Debasement↔Bitcoin, AI↔Data-Ownership). The copilot partners sit with to sharpen the narrative. Output = a living "messaging source of truth." | On-demand, collaborative | Claude | Partner sign-off on canonical thesis |
|
||||
| **Scribe** | **Distribution / amplification.** Takes the Architect's canonical thesis + your content (Bitcoin Alpha, partner shows, memos) and propagates segment-specific cuts across X, nostr, LinkedIn, email. | Scheduled + on-demand | Claude | Review before publish |
|
||||
| **Closer** | Drafts personalized outreach and nurture sequences, preps partners before LP calls, writes follow-ups, keeps the CRM clean. | Triggered + on-demand | Claude | **Hard gate** — human sends all outbound |
|
||||
| **Orchestrator** ("Chief of Staff") | Schedules runs, routes work between agents, escalates to a human. | Always on | Claude (light) | n/a |
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# Ten31 Investment Thesis — Handoff Document (v2.0)
|
||||
|
||||
*For the narrative-architect agent workshopping the thesis with Grant. Self-contained: assume zero prior context.*
|
||||
|
||||
**Handoff version:** 2.0 · **Compiled:** 2026-06-08 · **Supersedes:** v1.0 (same date).
|
||||
**What v2.0 changes:** the *spine* (the throughline, the core structure, the portfolio mapping, and the proof-gap framing) was rebuilt to match the sharper thesis developed in the parallel **Ten31 signal-engine workstream**. The *apparatus* of v1.0 (provenance discipline, the proof-gap red-team, the deployed-vs-DPI rigor, the voice rules, the node/Architect tooling, the data-sovereignty boundary) is kept and in several places strengthened. See the changelog in §0.1.
|
||||
|
||||
*The thesis itself is a DRAFT. No canonical version is locked. Locking one is pending a Grant + Jonathan working session (not yet scheduled). If you pick this up later, confirm with Grant whether any open item below was resolved offline before acting on it.*
|
||||
|
||||
---
|
||||
|
||||
## 0. What backs each section (read first — provenance)
|
||||
|
||||
This document draws on three kinds of material, and you must know which is which.
|
||||
|
||||
| Section | Backed by | Re-readable? |
|
||||
|---|---|---|
|
||||
| Voice rules (9), segment angles (8), node/tooling (10) | `docs/thesis-seed-v5.md` + `docs/PHASE_1.md` (this project) | **Yes.** |
|
||||
| The spine — throughline + root (3), the seams (5), portfolio→seam mapping (6), proof-gap re-aim (7), conviction-log appendix (A) | The **parallel Ten31 signal-engine workstream** (a separate project; an extended thesis-development session with Grant) | **No, not from this project.** It is captured here and in the signal-engine handoff/conviction-log artifacts, which live in that other workstream. |
|
||||
| The five framings + /60 scores (4), the deployed-vs-DPI distinction, the Brookfield/hyperscaler proof-gap red-team | v1.0 of this handoff (the Architect agent's analysis) | **No.** v1.0 was the only record; this doc carries it forward. |
|
||||
|
||||
**Why this matters.** Two honest seams to flag:
|
||||
1. The spine in v2.0 (§§3, 5, 6, 7, A) was **not** produced in this project and is **not** in `thesis-seed-v5.md`. It comes from a parallel workstream Grant ran. Treat it as a working synthesis Grant has **engaged with deeply but not formally ratified in this project's documents**. Reconciling the two workstreams into one canonical backbone is itself an open item (§11).
|
||||
2. The single most important structural fact: **the thesis backbone in this document and the conviction log in the signal-engine project are the same object viewed from two angles.** If they drift, you will have an internal engine hunting derivatives of one thesis while investor comms articulate a subtly different one. They should share one source. Appendix A is the condensed shared backbone; the signal-engine project holds the live version.
|
||||
|
||||
### 0.1 Changelog v1.0 → v2.0 (so you can diff)
|
||||
- **§3 throughline rewritten.** v1.0's spine was "energy, compute, and AI infrastructure *settle on bitcoin*." That makes bitcoin-as-**settlement-rail / payments** the load-bearing claim — which is the *weaker* of Ten31's two bitcoin claims and is exactly what §7 then panics about. v2.0 rebuilds the spine on bitcoin-as-**apex non-debasable reserve asset**, with debasement as the forcing function and AI as the abundance engine. This is a smaller, more defensible leap, and it is the claim the portfolio actually expresses. (Empirical support, from the signal-engine backtest: Strike's *payments/settlement* thesis is the one that **died**; its value came from being a bitcoin *financial-services / reserve* play. v1.0 had internalized the dead thesis as the spine.)
|
||||
- **§5 pillars → three seams.** The three pillars are kept as "why we win" beats but re-grounded on three *connective* seams (Energy↔Compute, Debasement↔Bitcoin, AI↔Data-Ownership), which say *why* the markets are one thing rather than just asserting a shared bottleneck.
|
||||
- **§6 portfolio re-mapped to the seams**, and the "AI-infrastructure gap" reframed (it was partly an artifact of the wrong taxonomy — see §6).
|
||||
- **§7 proof-gap kept and re-aimed** at the reserve/scarcity claim, and a new proof standard imported: **milestone-vs-substance** (proof points must be falsifiable as scaled substance, not first-instance milestones).
|
||||
- **§4 framings table kept; the CONVERGENCE entry and the recommendation updated** to rest on the reserve/scarcity spine, not settlement.
|
||||
- **§10 data boundary strengthened**: it is the same scrub/rehydrate sovereignty boundary the signal-engine project uses; the two projects should share it.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Ten31 is, and how to use this doc
|
||||
|
||||
**Ten31** is an investment platform that raises from limited partners and deploys, mostly into **private** companies, across bitcoin, energy, AI infrastructure, and freedom technology. It backs **picks-and-shovels** — the scarce supply-side links — not toll roads.
|
||||
|
||||
Scale and track record (the proof you have to work with):
|
||||
- $200M+ deployed across two funds into 30+ companies.
|
||||
- Six-year track record including large-scale M&A and public-markets activity.
|
||||
- Fund III is live and continues the same strategy.
|
||||
- Partners: Grant and Jonathan. The thesis is the "messaging source of truth" every downstream agent (content, outreach, LP segmentation) reads from.
|
||||
|
||||
**Your job, picking this up:** help Grant and Jonathan converge the thesis from draft to canonical, then keep it evolving. Getting it right matters more than getting it fast.
|
||||
|
||||
**Two honesty rules that override everything else:**
|
||||
- **Do not invent proof.** Where a figure, deal name, or realized-return number is unknown, say so. A made-up realized return is the single most damaging thing you could put in this thesis.
|
||||
- **Do not over-expose proof.** Realized-return figures and deal-level "why we won this" specifics may be non-public and sensitive. Help Grant *structure and request* them; do not fabricate them, and do not echo real deal performance into a model context. See §10.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state — settled vs open
|
||||
|
||||
**Settled (treat as stable unless Grant reopens it):**
|
||||
- **The root and the throughline (§3).** Debasement is the forcing function; AI drives the marginal cost of the reproducible toward zero; durable value accrues to the provably scarce and verifiable; **AI is the abundance engine and bitcoin is the scarcity anchor — two faces of one megatrend, not two bets.** Bitcoin's role is **apex non-debasable reserve asset, not medium-of-exchange.** This spine is not in dispute.
|
||||
- **The three seams as substance (§5):** Energy↔Compute, Debasement↔Bitcoin, AI↔Data-Ownership. Wording will refine; the structure holds.
|
||||
- **Bitcoin-as-reserve, not payments.** This is settled and load-bearing. The contrarian leg is an *asset-value / capital-flow* claim, not a transactional-rail claim (this is what makes it defensible — see §3, §7).
|
||||
- **The voice rules (§9).**
|
||||
- **The recognition that the value-accrual claim is forward-looking conviction, not a description of today** — and that the gap between today and that future IS the opportunity.
|
||||
|
||||
**Drafted but conditional:**
|
||||
- **The five LP segment angles (§8)** are drafted, not locked. The **AI & energy operator** angle depends on the convergence positioning (§4), still open.
|
||||
|
||||
**Open (needs the partners, or needs proof Ten31 has not yet supplied):**
|
||||
- **The banner / positioning (§4).** Recommended path exists; not ratified.
|
||||
- **The proof gap (§7).** The most important open item in the document.
|
||||
- **Realized returns (DPI), and the 2–3 unlock deals** where bitcoin-alignment was the demonstrable edge. Unknown as written.
|
||||
- **Whether the AI seam is adequately expressed by current holdings** (§6) — the data-ownership leg is filled (Start9, Maple); whether you also want a physical-AI-compute-supply holding is a separate question.
|
||||
- **Whether pillar 3 (founder access) leads or supports** (§5).
|
||||
- **Reconciling this backbone with the signal-engine conviction log into one canonical object** (§0, §11).
|
||||
|
||||
---
|
||||
|
||||
## 3. The throughline and the root
|
||||
|
||||
### The root (the forcing function)
|
||||
Fiat is being debased — structural deficits financed by monetary expansion. At the same time, AI is collapsing the marginal cost of anything reproducible toward zero. When the reproducible becomes nearly free, durable economic value migrates to what remains **provably scarce and verifiable.** Bitcoin is the apex form of that: a fixed-supply, non-debasable, verifiable reserve asset. **AI is the abundance engine; bitcoin is the scarcity anchor. They are two faces of one megatrend, not two separate bets.**
|
||||
|
||||
### The throughline (the differentiated conviction)
|
||||
Bitcoin, AI, and energy are three of the largest growth markets of the next decade, and the scarce links across them — cheap energy, compute, and the non-debasable reserve asset — capture disproportionate value as the megatrend runs. Ten31's differentiated conviction is the specific connection: **as money debases and AI commoditizes the reproducible, value accrues to the scarce side of this one supply chain, and the monetary premium accrues to bitcoin.**
|
||||
|
||||
This is not "crypto + AI + energy" (everyone is saying that). It is the precise claim that the *scarce inputs* of these markets win, and that the monetary premium accrues to hard money. Almost no one else is making this specific connection.
|
||||
|
||||
### The exact verifiable / contrarian decomposition (the crux — use it consistently)
|
||||
This decomposition is what lets you lead with what a skeptic can check and earn only the part they cannot.
|
||||
|
||||
**Verifiable today (a skeptic can confirm):**
|
||||
- Power, compute, and AI infrastructure draw on the same scarce inputs (cheap energy, compute capacity). The bottleneck is physically co-located.
|
||||
- Fiat is being debased — checkable macro.
|
||||
- Bitcoin is provably scarce and non-debasable — true by construction (21M cap).
|
||||
- AI is collapsing the marginal cost of reproducible output — increasingly observable.
|
||||
|
||||
**Contrarian / forward-looking (Ten31's unique conviction, the unproven leg):**
|
||||
- As the reproducible goes to zero cost and money debases, durable value accrues to the provably scarce, and **bitcoin appreciates as the premier non-debasable reserve asset** — so the scarce links of the energy/compute/bitcoin supply chain capture outsized value.
|
||||
|
||||
Note what the contrarian leg **is and is not**. It is an **asset-value / capital-flow** claim (scarce assets win; the monetary premium accrues to bitcoin). It is **not** the claim that the world's commerce *clears in bitcoin* (the settlement/payments claim v1.0 rested on). That distinction is the whole reason this spine is defensible where v1.0's was not: "scarce assets win as money debases and AI commoditizes everything else" is a respectable macro conviction a serious LP can engage; "global infrastructure settles transactions in bitcoin" is a far larger and shakier leap. Lead with the verifiable co-location; earn the value-accrual conviction; never overclaim the transactional rail.
|
||||
|
||||
(Conceded weak forms — do not defend these, they are not the thesis: Ten31 is **not** betting on coffee-bought-in-bitcoin / retail payments at scale, and **not** on universal self-hosting. Stating either as the thesis invites the easiest rebuttal.)
|
||||
|
||||
---
|
||||
|
||||
## 4. The positioning question and the five framings
|
||||
|
||||
### Original framing of the question (from the seed)
|
||||
- **Option A (scarcity-forward):** "Ten31 invests in the infrastructure of scarcity."
|
||||
- **Option B (freedom tech as banner):** "Ten31 invests in freedom technology."
|
||||
|
||||
### What the Architect analysis found (v1.0, carried forward)
|
||||
Five framings, each scored /60 across **sharpness, differentiation, evidence-backing, segment-portability, credibility** (the rubric; per-dimension breakdowns were not preserved — if you generate a new framing, score it on these five axes and show the breakdown).
|
||||
|
||||
| Framing | Score | Banner / lead | Strength | Weakness |
|
||||
|---|---|---|---|---|
|
||||
| **CONVERGENCE** | **47/60** | "Bitcoin, AI, and energy are one supply chain. Ten31 owns the scarce links." | Opens from a physical fact a skeptic can verify, then earns the contrarian leg. The only framing that does not alienate AI/energy operators. | Must still earn the contrarian value-accrual leg (§7). |
|
||||
| **ACCESS / TRACK-RECORD** | 40/60 | "Founders bring us the deal before it exists." | Most proof-anchored. | "Proprietary deal flow" is venture's most overclaimed phrase; needs realized DPI to land. |
|
||||
| **ASYMMETRY** | 36/60 | Leads with the contrarian macro call. | Boldest. | Reads to an IC as a levered bitcoin proxy; too abstract. |
|
||||
| **SCARCITY / "chokepoints"** | 35/60 | "Chokepoints." | Vivid. | Overclaims a moat the proof does not show. |
|
||||
| **FREEDOM-TECH** | 28/60 | "Ten31 invests in freedom technology." | True to bitcoin-native identity. | Leads with the least underwritable word. Loses institutions, family offices, operators. |
|
||||
|
||||
### Current recommendation (updated in v2.0)
|
||||
It is **not** Option A vs Option B.
|
||||
|
||||
- **Freedom-tech loses as a banner.** Demote to a closing signal for bitcoin-native cuts only (e.g. the OG segment).
|
||||
- **Keep the scarcity substance, drop the "chokepoints" overclaim.**
|
||||
- **Lead with CONVERGENCE, then ACCESS/track-record as the proof beat.** Convergence opens on a verifiable physical fact and keeps operators in the room.
|
||||
|
||||
**v2.0 sharpening:** the convergence banner is directionally right, but it must rest on the **reserve/scarcity** engine from §3, not on settlement. The supply-chain metaphor is the LP-facing surface; the *reason* the three markets are one thing is the debasement-forcing-function / abundance-scarcity root, and the seams (§5) are the structure underneath. So the working recommendation is:
|
||||
|
||||
**Convergence banner (grounded in the abundance/scarcity root) → Access/track-record proof beat → the three seams as the substance → freedom-tech as a closing note for bitcoin-native audiences only.**
|
||||
|
||||
A recommendation, not a partner decision. Because the throughline changed in v2.0, this is a **trigger for the Consistency-check Architect move (§10): re-surface every downstream node that the reserve-not-settlement correction now conflicts with.**
|
||||
|
||||
---
|
||||
|
||||
## 5. The three seams (replaces "pillars" as the core structure)
|
||||
|
||||
v1.0 had three flat pillars. v2.0 keeps them as "why we win" beats but grounds the structure on three **connective seams** — each names a specific scarce thing meeting a specific abundant thing. This is more defensible than a shared-bottleneck assertion, and it maps cleanly to holdings (§6).
|
||||
|
||||
**Seam 1 — Energy ↔ Compute.** AI and bitcoin both compete for the same scarce input: cheap, firm, flexible power. The companies that own and supply the scarce side capture value as demand grows. Mining-native fluency (interruptible load, behind-the-meter, stranded-gas-to-power) is a real underwriting edge a generalist lacks.
|
||||
|
||||
**Seam 2 — Debasement ↔ Bitcoin.** As money debases, bitcoin is the non-debasable reserve, and the investable layer is the infrastructure to access, hold, leverage, and utilize it — custody, exchange, and especially **bitcoin as pristine collateral for credit.** Reserve and credit-collateral, **not** payments. (Mechanism worth naming: as bitcoin credit/insurance products mature, holders borrow rather than sell, shrinking marginal supply — scarcity amplifies.)
|
||||
|
||||
**Seam 3 — AI ↔ Data-Ownership.** As AI commoditizes baseline competence, profit on undifferentiated output erodes toward zero; durable margin accrues to those who own and protect their proprietary data and judgment. The investable layer is sovereign data + confidential inference (own your stack, own your inference). This is Ten31's published "coherence" conviction.
|
||||
|
||||
**The three "why we win" beats (the old pillars, re-grounded):**
|
||||
1. **Scarcity is the whole opportunity.** Each seam is bottlenecked on something scarce; owning the scarce side captures the value. The value-accrual-to-bitcoin connection is forward-looking conviction, and that gap is the opportunity.
|
||||
2. **Foundational infrastructure with real revenue today.** Companies generating energy, securing capital, powering computation — real businesses earning real money now. *(This doc's inference, not seed text: this is the pillar that grounds the forward-looking conviction in present-day substance, which institutions and family offices weight most. Test with Grant; do not quote as canonical copy.)*
|
||||
3. **Founders seek us out; we lead deals others never see.** Genuine bitcoin alignment plus a real track record. **Open question (in the seed):** lead with this, or supporting beat? The Architect put it as the proof beat behind convergence but flagged that "proprietary deal flow" overclaims without realized DPI.
|
||||
|
||||
---
|
||||
|
||||
## 6. The proof, and the portfolio mapped to the seams
|
||||
|
||||
### A definition you need: deployed vs DPI (kept from v1.0)
|
||||
- **Deployed capital** ("$200M+ deployed") is a *spend* metric. Says how much went in; nothing about what came out.
|
||||
- **DPI (Distributions to Paid-In)** is the *realized, cash-in-hand* return metric — of what LPs paid in, how much came back. (IRR is time-weighted; TVPI includes paper value; DPI is the realized one, which is why it converts a spend story into a performance story.)
|
||||
|
||||
This distinction is the spine of §7. The thesis has the spend metric and is missing the realized-return metric.
|
||||
|
||||
### The proof as it stands
|
||||
$200M+ deployed across two funds into 30+ companies. Six-year track record including large-scale M&A and public-markets activity. Fund III continues the strategy.
|
||||
|
||||
### The portfolio mapped to the seams
|
||||
The point of naming holdings is to make the convergence concrete — to show Ten31 already owns scarce links across the same supply chain the thesis describes. **Tags:** **[post-v5]** = from Grant's later verbal inputs, in no thesis doc yet; **[in seed]** = named in `thesis-seed-v5.md`.
|
||||
|
||||
| Seam | Holding | What it does | Notes / flags |
|
||||
|---|---|---|---|
|
||||
| **Energy ↔ Compute** | **Giga Energy** **[post-v5]** | Turns stranded/flared gas into power for compute/mining. | The convergence made physical — energy literally becoming compute. **Strongest single illustration of the throughline.** |
|
||||
| **Energy ↔ Compute** | **Satoshi Energy** **[post-v5]** | Connects energy producers to large-scale loads; bitcoin smart-power contracts. | Clean supply-side fit. |
|
||||
| **Energy ↔ Compute** | **Upstream** | Mining-focused operator. | The straddler-vs-pure-play contrast: mining-only, "whiffing on the AI play." Useful as the negative example of the seam. |
|
||||
| **Debasement ↔ Bitcoin** | **Strike** **[in seed]** | Bitcoin financial-services platform: exchange (buy/sell), one of the largest retail BTC-collateralized lenders, global access. | **Re-described in v2.0: reserve/financial-services, NOT payments.** The 2022 payments/Lightning-retail thesis did not pan out; the value is the bitcoin-bank re-rate. Largest single position. |
|
||||
| **Debasement ↔ Bitcoin** | **Battery Finance** | Dual-collateral (BTC + real asset) credit. | Demand-side real; institutional lending-capital (supply side) has lagged — a live test of timing on the bitcoin-as-collateral conviction. |
|
||||
| **Debasement ↔ Bitcoin** | **Unchained / AnchorWatch / debifi / Fold** | Custody + BTC-collateralized lending; BTC insurance (fiduciary unlock); bitcoin commercialization of a legacy rewards model (Fold). | The access/hold/leverage/utilize layer. |
|
||||
| **AI ↔ Data-Ownership** | **Start9** **[in seed]** | Self-hosted sovereign server / own-your-stack. | **Re-slotted in v2.0:** this is the data-ownership infra leg, not merely freedom-tech. (Honest caveat from the conviction log: conviction here is high *thematically* but exposure is low and adoption beyond the bitcoiner niche is unproven — "maybe drinking our own koolaid, tbd." Do not overstate.) |
|
||||
| **AI ↔ Data-Ownership** | **OpenSecret / Maple** | Confidential inference — own your inference even on remote silicon. | The "protect proprietary judgment during inference" leg of the coherence thesis. |
|
||||
| **AI (demand-side)** | **StatMuse** **[post-v5]** | AI-powered natural-language data/search. | **A real holding, but an AI *application* (demand-side), not the data-ownership infra leg.** Do not describe it as infrastructure; an operator LP would notice. It expresses AI exposure, not the AI-seam spine. |
|
||||
|
||||
### The AI-seam status (reframed in v2.0 — read carefully)
|
||||
v1.0 flagged a "gap": StatMuse is an AI application, not AI scarce-supply infrastructure, leaving the AI layer empty. **That gap was partly an artifact of defining the AI leg as physical compute supply.** Under the correct framing, Ten31's AI seam is **AI↔Data-Ownership** (own your data, judgment, inference — the coherence thesis), and **Start9 and Maple fill it.** So the leg is not empty.
|
||||
|
||||
What remains honest to say: if you specifically want a *physical AI-compute-supply* holding (data-center capacity, inference hardware), you are light there, and that is a legitimate, *smaller* admission than "our supply chain has a hole." Two things to resolve with Grant: (1) is the data-ownership framing the one you want the AI seam to carry (it is the published conviction), and (2) is physical AI-compute-supply a deliberate non-target or a Fund III sourcing gap? **Do not paper over either by calling StatMuse infrastructure.**
|
||||
|
||||
---
|
||||
|
||||
## 7. The KEY strategic finding — the proof gap (kept, re-aimed)
|
||||
|
||||
**Still the most important section. The red-team finding survives the v2.0 reframe — but it is now a smaller, closeable gap, because the spine no longer rests on the hardest possible claim.**
|
||||
|
||||
The thing Ten31 **uniquely owns** is the contrarian leg (value accrues to bitcoin as the non-debasable reserve as money debases and AI commoditizes the rest). The verifiable legs — energy generation, compute capacity — are exactly where Ten31 has **no structural edge** versus a generalist infrastructure giant (Brookfield) or a hyperscaler, who fund energy and compute with **cheaper capital at far greater scale.** So the differentiation cannot rest on the verifiable legs.
|
||||
|
||||
What changed in v2.0: the contrarian leg is now an **asset-value / capital-flow conviction** ("scarce assets win; the monetary premium accrues to bitcoin"), not a **settlement-rail claim** ("the world transacts in bitcoin"). The former is a defensible macro view a serious LP can engage; the latter was a far larger leap. So the thesis *claim* is now respectable; the thing that still must be proven is the **edge** — that Ten31 can access and underwrite this better than a generalist.
|
||||
|
||||
The practical test is unchanged: the answer to *"why not just give this money to Brookfield?"* cannot be "we also do energy." It has to be a **bitcoin-alignment unlock a generalist structurally could not have accessed** — the bitcoin-native founder network and the mining/energy-convergence underwriting fluency.
|
||||
|
||||
**The fix is the same everywhere the analysis looked:**
|
||||
1. **Name 2–3 deals where bitcoin-alignment was the demonstrable unlock** — a deal a generalist infra investor structurally could not have won. (Giga and Satoshi are candidates to examine; the unlock story must be made explicit, not assumed.)
|
||||
2. **Supply a realized return figure (DPI), not just "$200M deployed."**
|
||||
|
||||
### New proof standard imported in v2.0 — milestone vs. substance (do not skip)
|
||||
A finding from the signal-engine backtest, directly relevant to how you state proof: **proof points must be falsifiable as scaled substance, never as first-instance milestones.** Concretely — "a major institution entered bitcoin-collateralized lending" reads as **YES** on a single headline (Goldman executed one such loan in 2022), while "institutional capital arrived at scale" reads as **NO** (it had not, in-window). Same reality, opposite verdicts, purely from the altitude of the claim. A thesis that proves itself on milestone-checkboxes will look proven on a headline while the underlying conviction is unmet. So: every proof point and unlock claim must be stated as **scaled substance with a number** ("$X deployed / realized," "Y named institutions funding at scale"), not as "first to do Z."
|
||||
|
||||
**Until the unlock deals and at least one DPI figure exist, the contrarian value-accrual claim must be labeled UPSIDE OPTIONALITY, never the load-bearing premise.** The load-bearing premise is the verifiable, real-revenue infrastructure (beat 2) plus whatever realized track record exists. Let the unproven leg carry the pitch and a sharp IC collapses the whole thing to "a levered bitcoin proxy" (also a voice violation, §9, and the failure mode of the ASYMMETRY framing).
|
||||
|
||||
This finding frames how you workshop everything else: the goal of the next iteration is to **close this gap with real specifics**, not to phrase around it.
|
||||
|
||||
---
|
||||
|
||||
## 8. The five LP segments — angle and what to avoid
|
||||
|
||||
Angles from the seed (drafted, not locked). The operator angle is contingent on the still-open convergence decision (§4).
|
||||
|
||||
| Segment (id) | Who | Angle | Avoid |
|
||||
|---|---|---|---|
|
||||
| **Bitcoin-native HNWIs / OGs** (`btc_native_hnwi`) | Long-time bitcoiners | "Bitcoin only wins if people build on it. Holding is not enough." The one segment where the demoted freedom-tech note can surface. | Do not lecture OGs about bitcoin. |
|
||||
| **Institutions** (`institution`) | Institutional allocators | Credible exposure via a six-year track record including M&A and public-markets activity; reinforced by Grant's institutional pedigree. Lead with verifiable track record, not vision. | No hype. Do not open with the contrarian macro call. Vision before proof loses this room. |
|
||||
| **Family offices** (`family_office`) | Multi-generational capital | A long-horizon allocation grounded in real businesses and team credibility. The real-revenue beat does the most work. | Avoid trader framing. Patient allocators, not flippers. |
|
||||
| **Smaller accredited ($100k)** (`smaller_accredited`) | Accredited individuals | "The same thesis our most convicted investors back, at an accessible entry point." | Do not talk down. |
|
||||
| **AI & energy operators** (`ai_energy_operator`) | People who run AI/energy businesses | "You may not be focused on bitcoin today, and that is exactly the point." Meet them in their world (power, compute) and connect outward via the Energy↔Compute seam. **Contingent on the convergence framing** (the only one that does not alienate operators). | Do not preach bitcoin. Connect from their world outward. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Voice rules — follow exactly in any thesis copy
|
||||
|
||||
Hard constraints when you draft, quote, or propose thesis copy.
|
||||
|
||||
- **Direct, concrete, conviction-driven.** Plain sentences a serious LP can verify in their head, with real specifics where possible.
|
||||
- **No "betting" / "bet" / "gamble" language.** Ten31 invests with conviction. (Also defuses the "levered bitcoin proxy" frame — do not hand the reader that framing.)
|
||||
- **No em dashes.** Use periods, commas, or restructure.
|
||||
- **No "X, not Y" antithesis phrasing.** Do not construct a sentence as a contrast against a foil. (Note: the *internal* shorthand "reserve, not payments" used throughout this briefing is a clarity device for *you*; it is not LP copy and must be rewritten under this rule before it ships.)
|
||||
- **No kitchen-sink lists.** Pick the load-bearing specifics.
|
||||
- **Real specifics over vibes.** Use numbers/deals/facts where they exist; never manufacture one (§§1, 7).
|
||||
|
||||
**Caution about this document's own prose:** like v1.0, this briefing does **not** follow the LP voice rules — it uses em dashes, antithesis, and dense lists because it is an internal briefing. Do not lift sentences verbatim into the thesis. Write fresh under the rules above.
|
||||
|
||||
Self-check before shipping copy: read each sentence as a skeptical LP. Can they verify it, or does it ask for a leap? If a leap, it is either upside optionality (label it) or it needs proof attached.
|
||||
|
||||
---
|
||||
|
||||
## 10. How your output should be shaped, the tooling, and the data boundary
|
||||
|
||||
### What "workshopping" produces
|
||||
The thesis is a **tree of small typed nodes** (throughline → section → claim → proof-point → objection/rebuttal → segment cut), each with status. Iterating one claim should not re-litigate the whole narrative; competing phrasings are held as variants.
|
||||
|
||||
Two status vocabularies (do not conflate):
|
||||
- **Node status:** `draft | candidate | approved | retired`.
|
||||
- **Claim grounding status:** `draft | grounded | contested | retired`. A claim cannot leave `draft` without a pinned citation **and** a counter-evidence sweep (the negation framing).
|
||||
|
||||
Before producing output, **ask Grant which deliverable he wants**: polished prose thesis copy (voice rules), additional scored framing variants (the §4 rubric, with per-dimension breakdown), or structured claim/proof nodes with grounding status. Do not guess the format.
|
||||
|
||||
### The Architect's existing moves (reuse; do not reinvent)
|
||||
- **Vary** — ≥3 distinct framings of a target node, scored on the five dimensions.
|
||||
- **Revise** — turn a partner critique into a faithful before/after; never silently drop a framing the partner liked.
|
||||
- **Red-team** — anticipate LP objections per segment, each with a drafted answer + an honest "substantiated / hand-wavy" flag.
|
||||
- **Consistency-check** — when a throughline or pillar changes, surface every downstream node that now conflicts, with a proposed reconciliation. **Run this first in v2.0: the throughline changed from settlement to reserve/scarcity, so many downstream nodes likely conflict.**
|
||||
- **Substantiate (ground)** — attach citations and run the counter-evidence sweep.
|
||||
|
||||
**You cannot cross the canonical gate.** Promotion to canonical is a human-authenticated action (an admin partner, via a CRM route), logged. Stage candidates; never self-promote.
|
||||
|
||||
### The data-handling boundary (guardrail — do not skip; shared with the signal-engine project)
|
||||
The thesis *content* (non-LP-specific messaging substance) is generally fine to work on with a Claude model. The **evidence** that grounds it is sensitive, and the boundary here is the **same scrub/rehydrate sovereignty boundary the parallel signal-engine project uses** — treat them as one rule:
|
||||
- **Realized-return figures (DPI) and deal-level "why we won this" specifics may be non-public and confidential.** When closing the proof gap (§7), help Grant *structure the request* and *frame* the unlock stories. Do not fabricate them, and do not freely solicit or echo raw deal performance into a model context.
|
||||
- **Exposure / positioning data is the crown jewel.** As established in the signal-engine work: the most sensitive data should not cross the frontier boundary at all. Compute anything that needs it locally; scrub *identities*, never the substance the model must reason over.
|
||||
- Real LP conversation content used to ground claims must route through the project's redaction / re-hydration boundary before it reaches a Claude model. If a step would pull real record substance into context, flag it and route it through the boundary.
|
||||
|
||||
§1's "do not invent proof" has a twin: "do not over-expose proof." Honor both.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions to workshop (priority order)
|
||||
|
||||
1. **The proof gap (highest).** Which 2–3 deals demonstrate bitcoin-alignment was the unlock (a deal a generalist could not win)? What realized DPI can Ten31 stand behind? State both as **scaled substance, not milestones** (§7). Respect the data boundary (§10).
|
||||
2. **Ratify the positioning.** Accept/modify/reject: convergence banner (grounded in the abundance/scarcity root) → access/track-record proof → the three seams as substance → freedom-tech as a bitcoin-native closing note only (§4).
|
||||
3. **The AI seam.** Confirm AI↔Data-Ownership (Start9, Maple) as the AI leg; decide whether physical AI-compute-supply is a deliberate non-target or a Fund III sourcing gap (§6). Do not call StatMuse infrastructure.
|
||||
4. **Pillar 3's role.** Lead with founder access, or supporting beat? Note the overclaim risk on "proprietary deal flow" without DPI.
|
||||
5. **Banner wording.** Refine "Bitcoin, AI, and energy are one supply chain. Ten31 owns the scarce links" to final, under the voice rules, resting on the reserve/scarcity engine.
|
||||
6. **Per-holding unlock stories.** For Giga, Satoshi, Strike: the one-sentence "why a generalist could not have won this," as scaled substance.
|
||||
7. **Freedom-tech and Start9.** Confirm freedom-tech is a closing signal for bitcoin-native cuts only, and that Start9 carries the AI↔Data-Ownership seam (with the honest niche/adoption caveat) rather than being forced as freedom-tech alone.
|
||||
8. **Reconcile the two workstreams.** Merge this backbone and the signal-engine conviction log into one canonical object so the internal engine and the investor comms inherit the same thesis (§0). This is structural, not cosmetic.
|
||||
9. **Deliverable format for the Architect** (§10).
|
||||
10. **Lock a canonical version.** Once 1–4 and 8 resolve, seed the agreed thesis into the CRM as canonical (human-authenticated) and retire the draft status.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — The shared conviction backbone (condensed)
|
||||
|
||||
This is the **same object** as the signal-engine project's conviction log, summarized here so the thesis and the engine inherit one thesis. The live version lives in that workstream; keep them synchronized (§11 item 8). Each entry: the trackable thematic proposition, with conviction and current-exposure levels as Grant's working read (he finalizes, especially exposure).
|
||||
|
||||
**Root**
|
||||
- **R1 Debasement / neutral reserve** — fiat keeps being debased; bitcoin is adopted as the non-debasable reserve capital migrates to. *HIGH / pervasive.*
|
||||
- **R2 Abundance / scarcity** — AI drives the reproducible to ~zero cost; value accrues to the scarce/verifiable; bitcoin is the "strongest horse." *HIGH / thesis-wide.*
|
||||
- **R3 Sovereign + institutional adoption catalyst** — strategic reserves, bank custody (SAB-121 repeal), ETF/treasury inflows; career risk inverts. *MED-HIGH / pervasive.*
|
||||
|
||||
**Energy ↔ Compute**
|
||||
- **E1** Power, not chips, is the binding constraint on AI buildout (~2027–28); seam picks-and-shovels under-priced. *HIGH (Giga, Satoshi).*
|
||||
- **E2** The miner flexible-load playbook goes mainstream; mining fluency is a transferable underwriting edge. *HIGH.*
|
||||
- **E3** Straddlers (mining→AI/HPC) beat pure-plays; mining-only underperforms (Upstream). *MED.*
|
||||
|
||||
**Debasement ↔ Bitcoin**
|
||||
- **D1** Bitcoin-as-collateral goes mainstream; new credit products; spreads compress; ≥1 major institution funds *at scale* (substance, not milestone). Scarcity-amplification: borrow-not-sell shrinks supply. *HIGH / HIGH (Strike, Battery, Unchained, debifi, AnchorWatch).*
|
||||
- **D2** Incumbents buy, not build — strategic acquisitions of bitcoin-natives (the exit thesis). *HIGH.*
|
||||
- **D3** Bitcoin commercialization of legacy operating businesses. *MED-HIGH (Fold, AnchorWatch, Giga).*
|
||||
- **D4** Strike re-rates as a bitcoin bank, not payments. *HIGH; largest position (~40%); team + thesis bet (track the thesis half only).*
|
||||
|
||||
**AI ↔ Data-Ownership** *(prime under-acted-conviction: high published conviction, low exposure — mirrors the 2023 AI/compute miss)*
|
||||
- **A1** Owned judgment is the last margin (coherence) — sovereign data + confidential inference. *HIGH thematic / LOW exposure (Start9, Maple, Primal).*
|
||||
- **A2** Sovereign option for the segment that can't cede (regulated, IP-sensitive). *MED / LOW.*
|
||||
- **A3** Start9 broadens beyond the niche. *LOW / explicitly uncertain.*
|
||||
|
||||
**Monitored thesis-breakers (the thesis must be able to hear these against itself)**
|
||||
- **B1** Quantum acceleration breaks bitcoin's cryptography before mitigations deploy.
|
||||
- **B2** AI permanently outbids mining for power, collapsing the flexible-load edge.
|
||||
- **B3** Stablecoins / CBDCs capture the neutral-reserve role, or bitcoin fails as the exit.
|
||||
|
||||
*Structural rule (applies to both projects): conviction = team × thesis, but only the **thesis** half is trackable in a corpus or defensible in LP copy. Never present theme-corroboration as validation of the team bet.*
|
||||
|
||||
---
|
||||
|
||||
*Source files (this project): `docs/thesis-seed-v5.md` (voice, segments, node/tooling) and `docs/PHASE_1.md` (Architect tooling, rubric, gate). The v2.0 spine (§§3, 5, 6, 7, A) and the framings/proof-gap apparatus (§4, deployed-vs-DPI, the Brookfield red-team) have no re-readable source in this project — see §0. When in doubt about a fact, treat it as unknown and flag it; treat sensitive deal/LP specifics per §10.*
|
||||
@@ -9948,6 +9948,215 @@
|
||||
);
|
||||
};
|
||||
|
||||
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 [radar, setRadar] = useState([]);
|
||||
const [lastInvestor, setLastInvestor] = useState('');
|
||||
const [gmailBusy, setGmailBusy] = useState(false);
|
||||
const [gmailResult, setGmailResult] = useState(null);
|
||||
const GMAIL_FAIL = {
|
||||
no_recipient: "No email address on file for this investor, so there's nothing to address the draft to.",
|
||||
integration_disabled: 'Gmail integration is off on the server.',
|
||||
auth_error: 'Could not authorize Gmail — check the compose scope is authorized in Workspace admin.',
|
||||
no_sender: 'Your account has no email on file to draft from.',
|
||||
empty: 'The draft is empty.',
|
||||
gmail_error: 'Gmail rejected the draft.',
|
||||
};
|
||||
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 */ }
|
||||
try {
|
||||
const rr = await api('/api/outreach/radar', {}, token);
|
||||
if (!cancelled) setRadar(Array.isArray(rr?.items) ? rr.items : []);
|
||||
} catch (_) { /* none */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token]);
|
||||
|
||||
const draft = async (ovInvestor, ovType) => {
|
||||
if (drafting) return;
|
||||
const inv = ovInvestor || investorId;
|
||||
const t = ovType || type;
|
||||
if (!inv) { onShowToast('Pick an investor first', 'error'); return; }
|
||||
try {
|
||||
setDrafting(true);
|
||||
setResult(null);
|
||||
setGmailResult(null);
|
||||
setLastInvestor(inv);
|
||||
const res = await api('/api/outreach/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ investor_id: inv, outreach_type: t, 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 createGmailDraft = async () => {
|
||||
if (gmailBusy || !lastInvestor) return;
|
||||
try {
|
||||
setGmailBusy(true);
|
||||
setGmailResult(null);
|
||||
const res = await api('/api/outreach/gmail-draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ investor_id: lastInvestor, draft: draftText }),
|
||||
}, token);
|
||||
const d = res.data || res;
|
||||
setGmailResult(d);
|
||||
if (d.status === 'ok') onShowToast(d.threaded ? 'Reply draft created in your Gmail' : 'Draft created in your Gmail', 'success');
|
||||
else onShowToast(GMAIL_FAIL[d.status] || d.reason || 'Could not create the draft', 'error');
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Could not create the draft'), 'error');
|
||||
} finally {
|
||||
setGmailBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ok = result && result.status === 'ok';
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h2 className="section-title" style={{ marginBottom: '20px' }}>Outreach</h2>
|
||||
|
||||
{radar.length > 0 && (
|
||||
<div className="section">
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
Needs attention
|
||||
<span className="approval-pill">{radar.length}</span>
|
||||
</div>
|
||||
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||||
Investors who are waiting on a reply or have gone quiet, most urgent first. Every reason is verifiable from your email history — no guesswork. Click Draft to compose in your voice.
|
||||
</div>
|
||||
{radar.map((it) => (
|
||||
<div key={it.investor_id} className="merge-candidate-card"
|
||||
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{it.name}</div>
|
||||
<div className="kpi-subtitle">{it.reason}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setInvestorId(it.investor_id); setType(it.suggested_type); draft(it.investor_id, it.suggested_type); }}
|
||||
disabled={drafting}>
|
||||
{drafting ? '…' : `Draft ${it.suggested_type === 'nurture' ? 'nurture' : 'follow-up'}`}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<button onClick={createGmailDraft} disabled={gmailBusy}>
|
||||
{gmailBusy ? 'Creating…' : 'Create Gmail draft'}
|
||||
</button>
|
||||
</div>
|
||||
{gmailResult && gmailResult.status === 'ok' && (
|
||||
<div className="index-action-hint" style={{ marginTop: '8px' }}>
|
||||
{gmailResult.threaded ? 'In-thread reply draft' : 'Draft'} created in your Gmail. <a href={gmailResult.gmail_url} target="_blank" rel="noopener noreferrer">Open Gmail Drafts</a> to review and send.
|
||||
</div>
|
||||
)}
|
||||
<div className="index-action-hint" style={{ marginTop: '12px' }}>
|
||||
<strong>Voice based on:</strong>{' '}
|
||||
{result.voice_examples && result.voice_examples.length > 0
|
||||
? <>your codified rules + {result.voice_examples.length} of your prior emails — {result.voice_examples.map((v, i) => (
|
||||
<span key={i}>{i > 0 ? '; ' : ''}"{v.subject}"{v.date ? ` (${v.date})` : ''}</span>
|
||||
))}</>
|
||||
: 'your codified voice rules only (no prior emails of yours were found to learn from yet)'}
|
||||
</div>
|
||||
<div className="index-action-hint" style={{ marginTop: '6px' }}>
|
||||
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);
|
||||
@@ -10197,6 +10406,7 @@
|
||||
<div key={a.id} className="kpi-card">
|
||||
<div className="kpi-label">{a.email_address}</div>
|
||||
<div className="kpi-value" style={{ fontSize: '15px' }}>{a.sync_status || 'pending'}{a.backfill_complete ? '' : ' · backfilling'}</div>
|
||||
<div className="kpi-subtitle">{(a.captured ?? 0)} captured · {(a.matched ?? 0)} matched</div>
|
||||
<div className="kpi-subtitle">{a.last_synced_at ? `last ${formatDate(a.last_synced_at)}` : 'never synced'}{a.sync_error ? ` · ${a.sync_error}` : ''}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -10750,6 +10960,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>
|
||||
@@ -10785,6 +10998,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'}
|
||||
@@ -10817,6 +11031,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} />}
|
||||
|
||||
@@ -29,8 +29,17 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
||||
// * 0.1.0:61 (Email Capture: live backfill progress + auto-refresh)
|
||||
// * 0.1.0:62 (fix backfill crash on no-Reply-To emails; Sync now retries errored mailboxes)
|
||||
// * 0.1.0:63 (System Status: storage usage — DB, attachments, backups, disk free)
|
||||
// * Current: 0.1.0:64 (email-activity agent: propose->review->approve grid notes; sync ~15 min)
|
||||
export const PACKAGE_VERSION = '0.1.0:64'
|
||||
// * 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)
|
||||
// * 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
||||
// * 0.1.0:68 (Outreach Draft Assistant — tailored LP drafts via thesis + redaction boundary)
|
||||
// * 0.1.0:69 (follow-up radar — deterministic "needs attention" list + one-click draft)
|
||||
// * 0.1.0:70 (outreach voice upgrade — per-user voice from own emails + transparency; active-thread context)
|
||||
// * 0.1.0:71 (voice by-purpose larger sample + Tier-B: create Gmail draft w/ in-thread reply)
|
||||
// * 0.1.0:72 (stage v2.0 reserve-asset thesis spine as Workshop candidates)
|
||||
// * Current: 0.1.0:73 (replace old settlement spine with v2.0 reserve-asset spine across Architect + outreach prompts, seed constants, and docs; promote v2.0 to the working approved spine + soft-retire old settlement nodes, reversibly, node-level only)
|
||||
export const PACKAGE_VERSION = '0.1.0:73'
|
||||
|
||||
export const DATA_MOUNT_PATH = '/data'
|
||||
export const WEB_PORT = 8080
|
||||
|
||||
@@ -25,8 +25,17 @@ import { v_0_1_0_61 } from './v0.1.0.61'
|
||||
import { v_0_1_0_62 } from './v0.1.0.62'
|
||||
import { v_0_1_0_63 } from './v0.1.0.63'
|
||||
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'
|
||||
import { v_0_1_0_69 } from './v0.1.0.69'
|
||||
import { v_0_1_0_70 } from './v0.1.0.70'
|
||||
import { v_0_1_0_71 } from './v0.1.0.71'
|
||||
import { v_0_1_0_72 } from './v0.1.0.72'
|
||||
import { v_0_1_0_73 } from './v0.1.0.73'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0_64,
|
||||
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],
|
||||
current: v_0_1_0_73,
|
||||
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, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Email Capture: per-mailbox counts. Each enrolled mailbox card now shows how many
|
||||
// emails that mailbox captured and how many matched to investors (from the per-account
|
||||
// sighting table; emails are de-duplicated globally, so an email seen by two mailboxes
|
||||
// counts for each). Read-only. No schema migration.
|
||||
export const v_0_1_0_65 = VersionInfo.of({
|
||||
version: '0.1.0:65',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Email Capture now shows captured and matched-to-investor counts per Ten31 mailbox,',
|
||||
'so you can see each user’s email coverage, not just the totals.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// LP Objections page: a UI trigger for the Architect's grounding pass. An admin picks
|
||||
// a segment (or All LPs) and runs grounding — the Architect mines matched LP emails and
|
||||
// notes on the local model, removes every identifier through the redaction boundary,
|
||||
// and asks Claude for the recurring objections + honest rebuttals. Only de-identified
|
||||
// themes leave Ten31. Uses the existing /api/architect/ground route; no schema change.
|
||||
export const v_0_1_0_66 = VersionInfo.of({
|
||||
version: '0.1.0:66',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'New LP Objections page: run the Architect grounding pass over your matched LP email',
|
||||
'to surface the recurring objections (and the strongest honest rebuttals) for any',
|
||||
'segment. Everything sensitive is de-identified on your own hardware before Claude sees',
|
||||
'it — only objection themes leave Ten31. Results are a draft for your review.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Remove the LP Objections page. The summarize-historical-email grounding it ran
|
||||
// produced generic, unverifiable output with no quotes or source traceability, so it
|
||||
// is pulled from the UI. The redaction boundary it used is kept (reusable for the
|
||||
// proactive-outreach work); the backend /api/architect/ground route is left dormant
|
||||
// (no UI trigger). No schema change.
|
||||
export const v_0_1_0_67 = VersionInfo.of({
|
||||
version: '0.1.0:67',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Removed the LP Objections page — its historical email summary produced generic,',
|
||||
'unverifiable output. We are pivoting to proactive outreach and messaging instead.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
@@ -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 () => {} },
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Follow-up radar on the Outreach page. A deterministic scan surfaces investors who
|
||||
// need attention — "you owe a reply" (their email is unanswered), or a warm lead gone
|
||||
// quiet (no contact in 45+ days) — most urgent first, each with a checkable reason from
|
||||
// the email history (no LLM guesswork in the surfacing). One click drafts the suggested
|
||||
// follow-up/nurture in your voice via the existing outreach drafter. No schema change.
|
||||
export const v_0_1_0_69 = VersionInfo.of({
|
||||
version: '0.1.0:69',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Outreach now opens with a "Needs attention" list: investors waiting on a reply or gone',
|
||||
'quiet (45+ days), most urgent first, each with a reason you can verify from the email',
|
||||
'history. One click drafts the right follow-up or nurture in your voice.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Outreach voice upgrade. Drafts now learn each sender's own voice: the codified rules
|
||||
// plus a few-shot of that user's recent sent emails (de-identified), and the result
|
||||
// lists which of their emails were used (transparency — no black box). The recipient
|
||||
// context is restructured around the active conversation (the most recent email thread
|
||||
// is what you reply to; earlier emails are background). No schema change.
|
||||
export const v_0_1_0_70 = VersionInfo.of({
|
||||
version: '0.1.0:70',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Outreach drafts now sound like you: each user’s voice is learned from their own prior',
|
||||
'emails (plus the rules), and the draft shows exactly which of your emails it used. It',
|
||||
'also focuses on the most recent thread as the active conversation, with earlier emails',
|
||||
'as background.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Two outreach upgrades. (1) Voice now learns from the sender's prior emails OF THE SAME
|
||||
// PURPOSE (larger, purpose-weighted sample, not just recent). (2) Tier-B sending: with the
|
||||
// gmail.compose scope authorized, an approved draft can be created straight into the
|
||||
// sender's Gmail (and therefore Superhuman) Drafts — as an in-thread reply when there's an
|
||||
// active thread, or a fresh email otherwise. Our code only ever CREATES a draft; the human
|
||||
// sends from Gmail (guardrails #4, #6). No schema change.
|
||||
export const v_0_1_0_71 = VersionInfo.of({
|
||||
version: '0.1.0:71',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Outreach: drafts now learn your voice from your prior emails of the same purpose (a',
|
||||
'bigger, more relevant sample), and a new "Create Gmail draft" button drops the approved',
|
||||
'draft into your Gmail Drafts — as an in-thread reply for follow-ups — for you to review',
|
||||
'and send. Nothing is sent automatically.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Stage the v2.0 reserve-asset thesis spine (from the parallel signal-engine workstream)
|
||||
// as CANDIDATE nodes in the Thesis Workshop: root/forcing-function, throughline, the
|
||||
// verifiable-vs-contrarian decomposition, and the three seams (Energy↔Compute,
|
||||
// Debasement↔Bitcoin, AI↔Data-Ownership). Provenance + "unratified, exposure unconfirmed"
|
||||
// stated on the section. Additive, non-canonical (guardrail #4), idempotent. No migration.
|
||||
export const v_0_1_0_72 = VersionInfo.of({
|
||||
version: '0.1.0:72',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Thesis Workshop now shows the v2.0 reserve-asset spine (bitcoin as the non-debasable',
|
||||
'reserve, debasement as forcing function, AI as abundance engine) as candidate content',
|
||||
'for you and your partner to review. It is staged only — nothing is canonical without',
|
||||
'two admins signing off.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Replace the old "settlement / scarcity-as-connecting-idea" thesis spine with the v2.0
|
||||
// reserve-asset spine everywhere it was still encoded in LIVE code, the seed, and the docs:
|
||||
// * Architect system prompt (architect_agent.py) and the LP-outreach system prompt
|
||||
// (outreach_agent.py) — both hardcoded "scarcity as the connecting idea" and shipped the
|
||||
// settlement framing into every generated draft.
|
||||
// * Seed constants (thesis_seed.py): THROUGHLINE, PILLAR_1, the AI/energy-operator segment
|
||||
// angle, and the residual "monetary premium settles" verb in the staged v2.0 candidate.
|
||||
// * docs/thesis-handoff.md replaced wholesale with the complete v2.0 handoff; planning-doc
|
||||
// throughline glosses updated.
|
||||
// Plus a reversible, idempotent, NODE-LEVEL promotion (ensure_thesis_v2_promoted): the v2.0
|
||||
// candidate spine becomes the working APPROVED spine and the old settlement throughline + Pillar 1
|
||||
// are SOFT-retired (status='retired' + deleted_at; never hard-deleted — guardrail #3), so the live
|
||||
// agents stop emitting the dead spine. This does NOT cross the canonical gate (guardrail #4): no
|
||||
// thesis_version is set to canonical; freezing v2.0 as canonical remains the partners' dual-approval
|
||||
// action. The v2.0 spine is still an unratified draft pending the Grant + Jonathan working session.
|
||||
// Additive; no schema migration. The promote runs via init_db on boot (house pattern) and is
|
||||
// deployment-state-invariant; to roll the spine back, call thesis_seed.revert_thesis_v2_promotion(conn)
|
||||
// against crm.db (exact inverse of the captured node state) — the StartOS down migration stays a no-op.
|
||||
export const v_0_1_0_73 = VersionInfo.of({
|
||||
version: '0.1.0:73',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'The Thesis Workshop and both AI copilots (thesis Architect and the LP-outreach drafter) now',
|
||||
'run on the v2.0 reserve-asset spine: bitcoin as the apex non-debasable reserve asset, debasement',
|
||||
'as the forcing function, AI as the abundance engine. The old settlement framing is retired',
|
||||
'everywhere the agents read, reversibly. Nothing is canonical until two admins sign off.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user