Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling

Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
This commit is contained in:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+66 -12
View File
@@ -9,12 +9,15 @@ import { formatTime } from "./util.js";
// numbers are operational data, not configuration. Update when Google
// changes published rates.
export const PRICING = {
"gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 },
"gemini-3-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
// The five Gemini models we support. Verified against Google's
// official docs on 2026-05-12. Retired/never-existed IDs omitted.
"gemini-3.1-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
"gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 },
"gemini-2.5-pro": { input: 1.25, output: 10.00, thinking: 10.00 },
"gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 },
"gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 },
"gemini-3.1-flash-lite": { input: 0.10, output: 0.40, thinking: 0.40 },
// Fallback for unknown / future models — better an estimate than nothing.
"default": { input: 1.00, output: 5.00, thinking: 5.00 },
"default": { input: 1.00, output: 5.00, thinking: 5.00 },
};
// ── Cost calculation ────────────────────────────────────────────────────────
@@ -46,16 +49,67 @@ export function calcCost(modelName, usage) {
};
}
// ── Section-count target by VIDEO duration ─────────────────────────────────
// Mirrors recap-relay's computePerWindowTarget() (server/chunked-analyze.js).
// Operator-tunable on the relay; baked into code defaults here on the
// Recap-app direct path. The defaults match the relay's defaults so
// segmentation density is consistent across both pipelines.
//
// Buckets are TOTAL video duration in minutes:
// <30 → 6 sections / 30-60 → 8 / 60-90 → 9 / 90-120 → 10
// 120-150 → 11 / 150-180 → 12 / >=180 → 12
// Per-window target = total_target × window_sec / total_audio_sec
// (clamped to ≥1 for single-shot runs).
function pickTotalSectionsTarget(totalAudioSec) {
const m = (totalAudioSec || 0) / 60;
if (m < 30) return 6;
if (m < 60) return 8;
if (m < 90) return 9;
if (m < 120) return 10;
if (m < 150) return 11;
if (m < 180) return 12;
return 12;
}
function formatTargetSectionsLabel(avg) {
if (avg <= 1.2) return "1 section";
const lo = Math.max(1, Math.floor(avg));
const hi = Math.max(lo, Math.ceil(avg));
if (lo === hi) return "around " + lo + " sections";
return lo + "" + hi + " sections";
}
// ── Topic-analysis prompt builder ───────────────────────────────────────────
// Takes the parsed transcript entries and builds the JSON-output prompt
// fed to the analysis model. Indices in the response are positional into
// the same `entries` array — the caller relies on that contract.
export function buildAnalysisPrompt(entries) {
// Takes the parsed transcript entries for a WINDOW and builds the
// JSON-output prompt fed to the analysis model. Indices in the response
// are positional into the same window-entries array — the caller relies
// on that contract.
//
// `opts.totalAudioSec` is the FULL audio duration (not just this window),
// used to scale the section-count target via the per-video-duration table
// above. When omitted, falls back to deriving from the windowEntries
// themselves (legacy callers / unit tests / single-shot path).
export function buildAnalysisPrompt(entries, opts = {}) {
const numbered = entries
.map((e, i) => `[${i}] (${formatTime(e.offset)}) ${e.text}`)
.join("\n");
return `You are analyzing a video transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections.
// Window length in minutes (this window's own transcript span).
const windowSec = entries.length > 1
? (entries[entries.length - 1].offset || 0) - (entries[0].offset || 0)
: 0;
const windowMin = Math.max(1, Math.round(windowSec / 60));
const maxIndex = Math.max(0, entries.length - 1);
// Total audio duration drives the per-video-duration target picker.
// If the caller didn't supply it, assume this is a single-shot run
// and the window IS the whole audio.
const totalAudioSec = opts.totalAudioSec || windowSec || 60;
const totalTarget = pickTotalSectionsTarget(totalAudioSec);
const numWindows = Math.max(1, totalAudioSec / Math.max(60, windowSec || 60));
const avgPerWindow = totalTarget / numWindows;
const targetSections = formatTargetSectionsLabel(avgPerWindow);
return `You are analyzing a ~${windowMin}-minute section of a longer transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections — aim for ${targetSections}.
TRANSCRIPT (each line is numbered with a timestamp):
${numbered}
@@ -67,13 +121,13 @@ INSTRUCTIONS:
4. For each section, write:
- A short, specific topic title (3-8 words)
- A 1-3 sentence summary of what's discussed
- The start and end segment indices (inclusive)
- The start and end segment indices (inclusive), counted as the bracketed [N] number at the start of each transcript line above.
IMPORTANT:
- Sections must be chronological and non-overlapping.
- Every segment index from 0 to ${entries.length - 1} must belong to exactly one section.
- Every segment index from 0 to ${maxIndex} must belong to exactly one section.
- startIndex of section N+1 must equal endIndex of section N plus 1.
- Create as many or as few sections as the content naturally requires.
- Create as many or as few sections as the content naturally requires — but lean toward broad, substantive topics rather than minute-by-minute breakdowns. A natural topic that spans several minutes of dialogue should be one section, not several.
- Titles should be descriptive and specific, not generic like "Introduction" unless it truly is one.
Respond with ONLY valid JSON in this exact format, no other text: