Compare commits

..

10 Commits

Author SHA1 Message Date
Keysat 07af9257f4 Add VS Code launch config for CRM preview 2026-06-12 16:05:18 -05:00
Keysat fffc90c7a4 Replace v5 settlement spine with v2.0 reserve-asset spine (v0.1.0:73)
Swap the dead "scarcity as the connecting idea" / bitcoin-as-settlement
spine for the v2.0 reserve-asset spine (bitcoin = apex non-debasable
reserve asset; debasement = forcing function; AI = abundance engine;
throughline is an asset-value/capital-flow claim, not settlement; three
seams Energy<->Compute, Debasement<->Bitcoin, AI<->Data-Ownership)
everywhere it was still encoded in live code, the seed, and the docs.

- architect_agent.py / outreach_agent.py: both system prompts carried
  "scarcity as the connecting idea" and shipped settlement framing into
  every generated draft; rewritten to the reserve-asset spine.
- thesis_seed.py: THROUGHLINE, PILLAR_1, the AI/energy-operator segment
  angle, and THESIS_V2 corrected and voice-cleaned (no em dash / "X, not
  Y" / "bet"). PILLAR_2/3 (real revenue, founder access) kept.
- ensure_thesis_v2_promoted / revert_thesis_v2_promotion: make the v2.0
  spine the working APPROVED spine and re-ground/clean the core nodes,
  deployment-state-invariant (structural targeting, not body text) and
  fully reversible (captures prior body/title/status/deleted_at). NODE
  level only: never sets a thesis_version canonical (guardrail #4); no
  hard deletes (guardrail #3). Wired into init_db after the v2 candidate
  stage.
- docs/thesis-handoff.md replaced wholesale with the complete v2.0 doc;
  Ten31_Agentic_Build_Plan.md + PHASE_1.md throughline glosses updated.

The v2.0 spine remains an unratified draft from the signal-engine
workstream: canonical freeze stays the partners' dual sign-off, and
Appendix-A conviction/exposure figures stay Grant's working read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:22:24 -05:00
Keysat c53fdcb4a0 thesis: stage v2.0 reserve-asset spine as Workshop candidates (v0.1.0:72)
Incorporates the signal-engine workstream's v2.0 thesis correction: the spine is bitcoin
as the apex NON-DEBASABLE RESERVE ASSET (debasement = forcing function, AI = abundance
engine), NOT "infrastructure settles on bitcoin" (the settlement/payments claim — Strike's
payments thesis died in backtest). thesis_seed.ensure_thesis_v2_candidate stages the
v2.0 root/forcing-function, throughline, the verifiable-vs-contrarian decomposition, and
the 3 seams (Energy↔Compute, Debasement↔Bitcoin, AI↔Data-Ownership) as CANDIDATE nodes
under the core line (idempotent sentinel; provenance + "unratified, exposure unconfirmed"
on the section). Nothing canonical (guardrail #4). docs/thesis-handoff.md gets a
SUPERSEDED-spine banner pointing to v2.0.

NOT done (gated on partner ratification): the live THROUGHLINE/PILLAR_1 constants and
architect_agent.py's system prompt ("scarcity as the connecting idea") still encode the
old spine — until ratified+updated, Vary/Revise/outreach regenerate the old framing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:32:36 -05:00
Keysat 606b336a00 outreach: voice by-purpose (larger sample) + Tier-B Gmail draft creation (v0.1.0:71)
(1) Voice: _voice_examples now picks the sender's prior sent emails OF THE SAME PURPOSE
(PURPOSE_PATTERNS keyword cues per outreach type), larger sample (8) weighted by purpose
then recency — not just recent. meta carries on_topic for transparency.

(2) Tier-B sending (gmail.compose now authorized in Workspace DWD). New
email_integration/compose.py create_outreach_draft: mints a compose-scoped DWD token for
the sender (credentials._mint/access_token_for parameterized by scope; GMAIL_COMPOSE_SCOPE),
builds an RFC822 message, and POSTs gmail.drafts.create into the SENDER's mailbox — as an
in-thread reply (threadId + In-Reply-To/References, recipient = matched LP address) when
there's an active thread, else a fresh email. NEVER sends — the human sends from Gmail
(guardrails #4, #6). Route POST /api/outreach/gmail-draft; UI "Create Gmail draft" button +
"Open Gmail Drafts" link. Tests: test_compose.py (parse/reply-target/RFC822+threading).
Message construction unit-verified; the live drafts.create runs on the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:30:05 -05:00
Keysat 49f84ca9a4 outreach: per-user voice from own emails + transparency; active-thread context (v0.1.0:70)
Voice upgrade. draft_outreach now learns the SENDER's voice: the codified rules PLUS a
few-shot of that user's own recent sent emails (_voice_examples; from_email = the
sender, de-identified in the same scrub batch as the recipient context, reference-only).
The response returns which of the sender's emails were used (subject + date + recipient),
shown in the UI as "Voice based on: …" — transparency to avoid the black-box problem.
Falls back to rules-only with a clear note when the user has no captured sent email.

Context restructured: _context groups the investor's email by thread and labels the most
recent thread as the "Active conversation (what you are replying to)" with earlier emails
as background, so replies stay on-topic instead of dredging old threads.

Sender email resolved in handle_outreach_draft (users table by user_id). Test extended
(active/background split, voice examples + meta, no-sender fallback). Fixed a UI bug the
preview caught: the manual Draft button was onClick={draft}, which passed the click event
as the investor arg after draft() gained params -> circular-JSON error; now onClick={()=>draft()}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:06:38 -05:00
Keysat 787d580550 outreach: follow-up radar — deterministic "needs attention" + one-click draft (v0.1.0:69)
The Outreach page now opens with a "Needs attention" list. A deterministic scan
(outreach_agent.follow_up_radar) surfaces investors per the email history: tier 0 "you
owe a reply" (their email is the most recent, unanswered, >=3d), tier 1 flagged + quiet,
tier 2 warm lead gone quiet (no contact in >=45d). Most urgent first; every reason is
verifiable from the data (no LLM in the surfacing — the deliberate fix for the trust
problem that sank objection-grounding). Excludes graveyard; needs email history. One
click sets the investor + suggested type (follow-up/nurture) and runs the existing
outreach drafter. Route GET /api/outreach/radar. Test mcp/test_outreach.py extended
(owe-reply/warm-quiet/recent/graveyard/order). Verified live in preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:31:52 -05:00
Keysat b5619d61e1 outreach: Outreach Draft Assistant — tailored LP drafts (v0.1.0:68)
First proactive-messaging build. New "Outreach" page (all authenticated users): pick an
investor + type (intro / follow-up / fund update / meeting follow-up / nurture) + optional
guidance; the agent drafts a tailored LP email in Ten31's voice, grounded in the thesis +
that investor's CRM notes and matched email history. The draft is editable + copyable;
nothing is sent (draft-only — guardrails #4, #6).

Sovereignty: the thesis is Ten31's own non-sensitive messaging (to Claude as-is); the LP
context is scrubbed through the redaction boundary before Claude, drafted with placeholders,
and re-hydrated locally — the LP list never reaches the API. Fails closed (scrub_unavailable /
claude_not_configured / rehydrate_failed quarantines a hallucinated-token draft).

Backend: mcp/outreach_agent.py (context assembly + scrub + Claude + rehydrate, reusing
architect_agent's client/thesis/voice + the Boundary); routes GET /api/outreach/investors,
POST /api/outreach/draft; logged. Test mcp/test_outreach.py (context assembly). Verified in
preview: page/selector/types/guidance render, fail-closed at the key-less Claude step (scrub
ran locally first), success rendering verified with a mocked ok draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:06:46 -05:00
Keysat 0943aeb2df architect: remove LP Objections page — generic/unverifiable output (v0.1.0:67)
The summarize-historical-email grounding produced generic, boilerplate objections
with no quotes and no source traceability (the minimize step abstracts away the
actual email text; the newest-N corpus carries little real objection signal, so the
model pattern-completes). Pulled the page (ObjectionsPage component + nav + dispatch).
The redaction boundary is kept (reusable for proactive outreach); the dormant
/api/architect/ground route is left in place but has no UI trigger. Pivoting to
proactive outreach / messaging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:09:58 -05:00
Keysat c2b84a1f26 architect: LP Objections page — UI trigger for the grounding pass (v0.1.0:66)
New admin "LP Objections" page (frontend ObjectionsPage + nav). Pick a segment (or
All LPs) and Run grounding: the Architect mines matched LP emails + notes on the local
model, scrubs every identifier through the redaction boundary, and asks Claude for the
recurring objections + honest rebuttals (substantiated/hand-wavy flagged). Renders the
de-identified draft + an "N identifiers protected" badge; fail-closed statuses
(local_model_unavailable / scrub_unavailable / claude_not_configured / rehydrate_failed)
show a clear message. Uses the existing /api/architect/ground route. Verified in preview:
page + segment selector + Run; the local minimize/scrub legs actually ran against real
Spark on synthetic input and fail-closed correctly at the (key-less) Claude step;
success rendering verified with a mocked ok response.

NOT yet deployed — start-cli RPC to the box hit a transient transport error post a
StartOS hiccup (curl works, start-cli doesn't); CRM healthy at v0.1.0:65 meanwhile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:48:24 -05:00
Keysat 701e37b579 email: per-mailbox captured/matched counts on Email Capture (v0.1.0:65)
/api/email/accounts now returns captured + matched per account (from the per-mailbox
sighting table email_account_messages joined to emails; emails dedupe globally so an
email seen by two mailboxes counts for each). Each mailbox card on the Email Capture
page shows "<N> captured · <M> matched" so per-user coverage is visible, not just the
aggregate. Verified in preview with two seeded mailboxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:10:51 -05:00
25 changed files with 1680 additions and 26 deletions
+11
View File
@@ -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
}
]
}
+92
View File
@@ -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"}
+10 -6
View File
@@ -32,6 +32,9 @@ from . import errors
GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" 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" GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
@@ -61,13 +64,14 @@ class DWDCredentialProvider:
self._cache: dict[str, AccessToken] = {} self._cache: dict[str, AccessToken] = {}
self._lock = threading.Lock() 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: with self._lock:
cached = self._cache.get(email_address) cached = self._cache.get(key)
if cached and cached.expires_at - time.time() > 60: if cached and cached.expires_at - time.time() > 60:
return cached return cached
token = self._mint(email_address) token = self._mint(email_address, scope)
self._cache[email_address] = token self._cache[key] = token
return token return token
def revoke(self, email_address: str) -> None: def revoke(self, email_address: str) -> None:
@@ -78,7 +82,7 @@ class DWDCredentialProvider:
# ------------------------------------------------------------------ helpers # ------------------------------------------------------------------ helpers
def _mint(self, subject_email: str) -> AccessToken: def _mint(self, subject_email: str, scope: str = GMAIL_READONLY_SCOPE) -> AccessToken:
try: try:
from cryptography.hazmat.primitives import hashes, serialization # type: ignore from cryptography.hazmat.primitives import hashes, serialization # type: ignore
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
@@ -92,7 +96,7 @@ class DWDCredentialProvider:
claim = { claim = {
"iss": self._client_email, "iss": self._client_email,
"sub": subject_email, "sub": subject_email,
"scope": GMAIL_READONLY_SCOPE, "scope": scope,
"aud": GOOGLE_TOKEN_URL, "aud": GOOGLE_TOKEN_URL,
"iat": now, "iat": now,
"exp": now + 3600, "exp": now + 3600,
+16
View File
@@ -162,6 +162,22 @@ def _h_list_accounts(handler):
"FROM email_accounts ORDER BY email_address" "FROM email_accounts ORDER BY email_address"
) )
rows = [dict(r) for r in cur.fetchall()] 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: finally:
conn.close() conn.close()
# Non-admins only see their own row # Non-admins only see their own row
+62
View File
@@ -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()
+16 -1
View File
@@ -58,7 +58,22 @@ def _render_thesis(thesis):
def _system(thesis): def _system(thesis):
text = ("You are the Architect, the in-house copilot that sharpens Ten31's investment " 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, " "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" f"VOICE RULES (follow exactly): {VOICE}\n\n"
"Here is the current working thesis:\n" + _render_thesis(thesis)) "Here is the current working thesis:\n" + _render_thesis(thesis))
# Cache the thesis context so iterating across requests is cheap. # Cache the thesis context so iterating across requests is cheap.
+254
View File
@@ -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}
+118
View File
@@ -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()
+119
View File
@@ -60,10 +60,12 @@ try:
import architect_tools as _architect_tools # type: ignore import architect_tools as _architect_tools # type: ignore
import architect_agent as _architect_agent # type: ignore import architect_agent as _architect_agent # type: ignore
import architect_grounding as _architect_grounding # type: ignore import architect_grounding as _architect_grounding # type: ignore
import outreach_agent as _outreach_agent # type: ignore
except Exception: except Exception:
_architect_tools = None _architect_tools = None
_architect_agent = None _architect_agent = None
_architect_grounding = None _architect_grounding = None
_outreach_agent = None
# ─── Configuration ──────────────────────────────────────────────────────────── # ─── Configuration ────────────────────────────────────────────────────────────
@@ -485,6 +487,22 @@ def init_db():
except Exception as _e: except Exception as _e:
print(f"[thesis] positioning framings warning: {_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() conn.close()
print(f"Database initialized at {DB_PATH}") print(f"Database initialized at {DB_PATH}")
@@ -1804,6 +1822,10 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_system_status(user) return self.handle_system_status(user)
if path == '/api/activity/proposals': if path == '/api/activity/proposals':
return self.handle_list_activity_proposals(user) 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 # Users
if path == '/api/users': if path == '/api/users':
@@ -1907,6 +1929,10 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_node_feedback(user, path.split('/')[-2], body) return self.handle_node_feedback(user, path.split('/')[-2], body)
if path == '/api/architect/ground': if path == '/api/architect/ground':
return self.handle_architect_ground(user, body) 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): if re.match(r'^/api/activity/proposals/[^/]+/approve$', path):
return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body) return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body)
if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path): if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path):
@@ -3905,6 +3931,99 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"data": res}) 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) ─── # ─── Architect thesis (Phase 1) ───
def handle_list_thesis_lines(self, user): def handle_list_thesis_lines(self, user):
if thesis_review is None: if thesis_review is None:
+243 -13
View File
@@ -60,11 +60,16 @@ def _node(conn, line_id, parent_id, node_type, ordn, title, body, status="draft"
THROUGHLINE = ( THROUGHLINE = (
"Bitcoin, AI, and energy are three of the largest growth markets of the next decade, " "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 " "and the scarce links across them (cheap energy, compute, and the non-debasable reserve "
"believe that energy, compute, and AI infrastructure will settle on money that is hard " "asset) capture disproportionate value as the megatrend runs. Fiat is being debased "
"to produce. That is not the case today, and connecting these markets to bitcoin is the " "through structural deficits financed by monetary expansion, and AI is collapsing the "
"part of the thesis that very few others are making, even as broader crypto tries to " "marginal cost of anything reproducible toward zero. As money debases and AI commoditizes "
"attach itself to AI and energy. Ten31 invests in that infrastructure with strong conviction." "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 = ( OPTION_A = (
@@ -78,11 +83,14 @@ OPTION_B = (
PILLAR_1 = ( PILLAR_1 = (
"Every one of these markets is bottlenecked on something scarce. AI and bitcoin both " "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 " "compete for cheap energy and compute, and each seam pairs a scarce input with an "
"infrastructure will increasingly settle on money that is hard to produce, which points " "abundant one: energy with compute, debasement with bitcoin as the non-debasable reserve, "
"directly at bitcoin. The companies that own and supply the scarce side of that equation " "AI with sovereign data ownership. As money debases and AI drives the reproducible toward "
"capture the value as demand grows. That is where we invest. (The bitcoin connection is a " "zero cost, durable value accrues to the provably scarce side of that supply chain, and "
"forward-looking conviction, not a description of today. That gap is the opportunity.)" "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 = ( PILLAR_2 = (
"We invest in foundational infrastructure with real revenue: the companies that generate " "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."), "Don't talk down to them; it is the same thesis, just an accessible entry point."),
("ai_energy_operator", "AI & energy operators", ("ai_energy_operator", "AI & energy operators",
"Operators in AI and energy who are not yet focused on bitcoin.", "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 " "You may not be focused on bitcoin today, and that is exactly the point. AI and bitcoin "
"becomes a larger component of energy and compute over time, and most operators in your " "both compete for the same scarce input, cheap and flexible power, and most operators in "
"space are not yet positioned for it. We are, and we invest across the stack that connects them.", "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 " "Don't assume they're bitcoin-focused and don't preach; connect bitcoin as a growing "
"component of their world over time."), "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}) {"count": len(inserted), "source": "architect-pass-2026-06-05", "node_ids": inserted})
conn.commit() conn.commit()
print(f"[thesis] seeded {len(inserted)} Architect positioning framings into the Workshop") 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
View File
@@ -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) ## 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: 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 **35 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*. - [ ] **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. - [ ] **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?). - [ ] **Approval policy:** who may promote a thesis version to canonical (any admin? Grant specifically? dual partner sign-off?).
+1 -1
View File
@@ -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) | | **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) | | **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 | | **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 | | **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 | | **Orchestrator** ("Chief of Staff") | Schedules runs, routes work between agents, escalates to a human. | Always on | Claude (light) | n/a |
+319
View File
@@ -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 23 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 23 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 23 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 14 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 (~202728); 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.*
+215
View File
@@ -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 EmailCapturePage = ({ token, user, onShowToast }) => {
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
@@ -10197,6 +10406,7 @@
<div key={a.id} className="kpi-card"> <div key={a.id} className="kpi-card">
<div className="kpi-label">{a.email_address}</div> <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-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 className="kpi-subtitle">{a.last_synced_at ? `last ${formatDate(a.last_synced_at)}` : 'never synced'}{a.sync_error ? ` · ${a.sync_error}` : ''}</div>
</div> </div>
))} ))}
@@ -10750,6 +10960,9 @@
<button className={`nav-item ${page === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}> <button className={`nav-item ${page === 'thesis-workshop' ? 'active' : ''}`} onClick={() => setPage('thesis-workshop')}>
<span className="nav-item-icon"></span> Thesis Workshop <span className="nav-item-icon"></span> Thesis Workshop
</button> </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')}> <button className={`nav-item ${page === 'system-status' ? 'active' : ''}`} onClick={() => setPage('system-status')}>
<span className="nav-item-icon"></span> System Status <span className="nav-item-icon"></span> System Status
</button> </button>
@@ -10785,6 +10998,7 @@
{page === 'communications' && 'Communications'} {page === 'communications' && 'Communications'}
{page === 'thesis' && 'Thesis'} {page === 'thesis' && 'Thesis'}
{page === 'thesis-workshop' && 'Thesis Workshop'} {page === 'thesis-workshop' && 'Thesis Workshop'}
{page === 'outreach' && 'Outreach'}
{page === 'system-status' && 'System Status'} {page === 'system-status' && 'System Status'}
{page === 'email-capture' && 'Email Capture'} {page === 'email-capture' && 'Email Capture'}
{page === 'feature-requests' && 'Feature Requests'} {page === 'feature-requests' && 'Feature Requests'}
@@ -10817,6 +11031,7 @@
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />} {page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />} {page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis-workshop' && <ThesisWorkshopPage 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 === 'system-status' && <SystemStatusPage token={token} user={user} onShowToast={showToast} />}
{page === 'email-capture' && <EmailCapturePage 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} />} {page === 'feature-requests' && <FeatureRequestsPage token={token} onShowToast={showToast} user={user} />}
+11 -2
View File
@@ -29,8 +29,17 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:61 (Email Capture: live backfill progress + auto-refresh) // * 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: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) // * 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) // * 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: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 DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080 export const WEB_PORT = 8080
+11 -2
View File
@@ -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_62 } from './v0.1.0.62'
import { v_0_1_0_63 } from './v0.1.0.63' 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_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({ export const versionGraph = VersionGraph.of({
current: v_0_1_0_64, 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], 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],
}) })
+16
View File
@@ -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 users email coverage, not just the totals.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+19
View File
@@ -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 () => {} },
})
+17
View File
@@ -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 () => {} },
})
+21
View File
@@ -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 Ten31s voice using the thesis + that investors 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 () => {} },
})
+18
View File
@@ -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 () => {} },
})
+19
View File
@@ -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 users 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 () => {} },
})
+20
View File
@@ -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 () => {} },
})
+19
View File
@@ -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 () => {} },
})
+32
View File
@@ -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 () => {} },
})