mcp-scraper 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +11 -3
  2. package/dist/{analytics-repository-WWXVU3VW.js → analytics-repository-IKE53S3E.js} +10 -2
  3. package/dist/bin/api-server.cjs +840 -177
  4. package/dist/bin/api-server.cjs.map +1 -1
  5. package/dist/bin/api-server.js +4 -4
  6. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  7. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  8. package/dist/bin/mcp-scraper-cli.js +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs +1 -1
  10. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  11. package/dist/bin/mcp-scraper-install.js +1 -1
  12. package/dist/bin/mcp-stdio-server.cjs +141 -35
  13. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  14. package/dist/bin/mcp-stdio-server.js +4 -4
  15. package/dist/bin/paa-harvest.cjs.map +1 -1
  16. package/dist/bin/paa-harvest.js +3 -3
  17. package/dist/{chunk-MFNGUM4L.js → chunk-2XTYLJQK.js} +15 -1
  18. package/dist/chunk-2XTYLJQK.js.map +1 -0
  19. package/dist/{chunk-YW2LXLDU.js → chunk-5BEPOFKG.js} +160 -15
  20. package/dist/chunk-5BEPOFKG.js.map +1 -0
  21. package/dist/chunk-73MUQSWC.js +7 -0
  22. package/dist/chunk-73MUQSWC.js.map +1 -0
  23. package/dist/{chunk-MFE6RMIA.js → chunk-GOZIG6HD.js} +2 -2
  24. package/dist/{chunk-R66PJOZW.js → chunk-NNZXZTJ2.js} +2 -2
  25. package/dist/{chunk-64WBUDPC.js → chunk-O63VVCCE.js} +143 -37
  26. package/dist/chunk-O63VVCCE.js.map +1 -0
  27. package/dist/{chunk-6DPIE262.js → chunk-QHVFKFME.js} +2 -2
  28. package/dist/{chunk-PODASLGT.js → chunk-QXAY44SA.js} +2 -2
  29. package/dist/{chunk-ZEMHL36J.js → chunk-YMSZO62N.js} +2 -2
  30. package/dist/{db-A4YUPV4Z.js → db-UPSS5BGM.js} +2 -2
  31. package/dist/editorial-reading-room/assets/app.js +62 -0
  32. package/dist/editorial-reading-room/assets/index.html +8 -0
  33. package/dist/editorial-reading-room/assets/styles.css +39 -0
  34. package/dist/{extract-bundle-2HONVC5T.js → extract-bundle-RCTNANCH.js} +3 -3
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.js +3 -3
  37. package/dist/{location-data-repository-YWFJGNIP.js → location-data-repository-UUSHH5M2.js} +3 -3
  38. package/dist/{server-JE7SLZLJ.js → server-IGIIEHXY.js} +518 -135
  39. package/dist/server-IGIIEHXY.js.map +1 -0
  40. package/dist/{site-extract-repository-STNOI5OH.js → site-extract-repository-3VQAALNR.js} +3 -3
  41. package/dist/{worker-4MHZS3OB.js → worker-KQN673JF.js} +5 -5
  42. package/package.json +1 -1
  43. package/dist/chunk-64WBUDPC.js.map +0 -1
  44. package/dist/chunk-MFNGUM4L.js.map +0 -1
  45. package/dist/chunk-XLEDHV3J.js +0 -7
  46. package/dist/chunk-XLEDHV3J.js.map +0 -1
  47. package/dist/chunk-YW2LXLDU.js.map +0 -1
  48. package/dist/server-JE7SLZLJ.js.map +0 -1
  49. /package/dist/{analytics-repository-WWXVU3VW.js.map → analytics-repository-IKE53S3E.js.map} +0 -0
  50. /package/dist/{chunk-MFE6RMIA.js.map → chunk-GOZIG6HD.js.map} +0 -0
  51. /package/dist/{chunk-R66PJOZW.js.map → chunk-NNZXZTJ2.js.map} +0 -0
  52. /package/dist/{chunk-6DPIE262.js.map → chunk-QHVFKFME.js.map} +0 -0
  53. /package/dist/{chunk-PODASLGT.js.map → chunk-QXAY44SA.js.map} +0 -0
  54. /package/dist/{chunk-ZEMHL36J.js.map → chunk-YMSZO62N.js.map} +0 -0
  55. /package/dist/{db-A4YUPV4Z.js.map → db-UPSS5BGM.js.map} +0 -0
  56. /package/dist/{extract-bundle-2HONVC5T.js.map → extract-bundle-RCTNANCH.js.map} +0 -0
  57. /package/dist/{location-data-repository-YWFJGNIP.js.map → location-data-repository-UUSHH5M2.js.map} +0 -0
  58. /package/dist/{site-extract-repository-STNOI5OH.js.map → site-extract-repository-3VQAALNR.js.map} +0 -0
  59. /package/dist/{worker-4MHZS3OB.js.map → worker-KQN673JF.js.map} +0 -0
@@ -41,11 +41,12 @@ function harvestTimeoutBudget(maxQuestions, serpOnly = false) {
41
41
  const clientMs = Math.min(serverMs + CLIENT_OVER_SERVER_MARGIN_MS, VERCEL_FUNCTION_MAX_MS - 5e3);
42
42
  return { serverMs, clientMs };
43
43
  }
44
- var VERCEL_FUNCTION_MAX_MS, CLIENT_OVER_SERVER_MARGIN_MS;
44
+ var VERCEL_FUNCTION_MAX_MS, DEFAULT_TOOL_CLIENT_TIMEOUT_MS, CLIENT_OVER_SERVER_MARGIN_MS;
45
45
  var init_harvest_timeout = __esm({
46
46
  "src/harvest-timeout.ts"() {
47
47
  "use strict";
48
48
  VERCEL_FUNCTION_MAX_MS = 3e5;
49
+ DEFAULT_TOOL_CLIENT_TIMEOUT_MS = 3e5;
49
50
  CLIENT_OVER_SERVER_MARGIN_MS = 15e3;
50
51
  }
51
52
  });
@@ -4067,6 +4068,7 @@ async function migrate(options = {}) {
4067
4068
  `);
4068
4069
  await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_user_status ON concurrency_locks(user_id, status, expires_at)`);
4069
4070
  await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_operation ON concurrency_locks(operation, status, expires_at)`);
4071
+ await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_active_user ON concurrency_locks(user_id, expires_at) WHERE status = 'active'`);
4070
4072
  await db.execute(`
4071
4073
  CREATE TABLE IF NOT EXISTS ledger (
4072
4074
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -4253,6 +4255,16 @@ async function migrate(options = {}) {
4253
4255
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
4254
4256
  )
4255
4257
  `);
4258
+ await db.execute(`
4259
+ CREATE TABLE IF NOT EXISTS connected_account_charges (
4260
+ user_id INTEGER NOT NULL,
4261
+ connection_id TEXT NOT NULL,
4262
+ period_start TEXT NOT NULL,
4263
+ amount_mc INTEGER NOT NULL,
4264
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
4265
+ PRIMARY KEY (user_id, connection_id, period_start)
4266
+ )
4267
+ `);
4256
4268
  await db.execute(`
4257
4269
  CREATE TABLE IF NOT EXISTS billing_credits (
4258
4270
  idempotency_key TEXT PRIMARY KEY,
@@ -4441,6 +4453,7 @@ async function migrate(options = {}) {
4441
4453
  )
4442
4454
  `);
4443
4455
  await db.execute(`CREATE INDEX IF NOT EXISTS request_events_user_created_at ON request_events(user_id, created_at DESC)`);
4456
+ await db.execute(`CREATE INDEX IF NOT EXISTS request_events_created_at ON request_events(created_at)`);
4444
4457
  await db.execute(`
4445
4458
  CREATE TABLE IF NOT EXISTS workflow_schedules (
4446
4459
  id TEXT PRIMARY KEY,
@@ -6746,6 +6759,7 @@ var init_db = __esm({
6746
6759
  "billing_debits_user_status",
6747
6760
  "billing_events_authorization",
6748
6761
  "billing_events_user_created_at",
6762
+ "concurrency_locks_active_user",
6749
6763
  "concurrency_locks_operation",
6750
6764
  "concurrency_locks_user_status",
6751
6765
  "credit_lots_user_active",
@@ -6765,6 +6779,7 @@ var init_db = __esm({
6765
6779
  "oauth_tokens_identity",
6766
6780
  "page_snapshots_user_checked_at",
6767
6781
  "prt_user_id",
6782
+ "request_events_created_at",
6768
6783
  "request_events_user_created_at",
6769
6784
  "scheduled_run_view_links_expiry",
6770
6785
  "scheduled_run_view_links_owner_run",
@@ -17503,7 +17518,8 @@ ${t.topQuestions.map((q) => `- ${q}`).join("\n")}` : ""
17503
17518
  ${q.threadUrl}` : ""}`).join("\n");
17504
17519
  const full = [
17505
17520
  `# Reddit Trending: "${d.topic || input.topic}"${subreddit ? ` in r/${subreddit}` : ""}`,
17506
- `**${totals.threads} threads \xB7 ${totals.upvotes} upvotes \xB7 ${totals.comments} comments** \xB7 last ${d.window === "7d" ? "week" : "month"} \xB7 ${d.threadsScraped ?? 0} of ${d.candidatesFound ?? threads.length} discovered scraped${d.partial ? " (partial \u2014 hit the time limit)" : ""}`,
17521
+ `**${totals.threads} threads \xB7 ${totals.upvotes} upvotes \xB7 ${totals.comments} comments** \xB7 last ${d.window === "7d" ? "week" : "month"} \xB7 ${d.threadsScraped ?? 0} of ${d.candidatesFound ?? threads.length} discovered scraped${d.partial ? " (partial)" : ""}`,
17522
+ d.degradedResult ? `**Discovery degraded:** ${(d.degradationReasons ?? []).join(", ") || "no usable discovery surface"}${d.retryRecommended ? " \xB7 retry recommended" : ""}${d.billingRefunded ? " \xB7 discovery charge refunded" : ""}` : d.discoverySource === "reddit_search_fallback" ? "**Discovery fallback:** direct Reddit search was used after the primary SERP returned no usable threads." : "",
17507
17523
  `
17508
17524
  ## Ranked threads
17509
17525
  ${threadBlocks || "_No threads found._"}`,
@@ -17535,7 +17551,13 @@ ${questionList || "_No questions extracted._"}`,
17535
17551
  threadsScraped: Number(d.threadsScraped ?? 0),
17536
17552
  candidatesFound: Number(d.candidatesFound ?? threads.length),
17537
17553
  partial: Boolean(d.partial),
17538
- searchQuery: d.searchQuery ?? ""
17554
+ searchQuery: d.searchQuery ?? "",
17555
+ discoverySource: d.discoverySource ?? "google_serp",
17556
+ resultQuality: d.resultQuality ?? "complete",
17557
+ degradedResult: Boolean(d.degradedResult),
17558
+ degradationReasons: d.degradationReasons ?? [],
17559
+ retryRecommended: Boolean(d.retryRecommended),
17560
+ billingRefunded: Boolean(d.billingRefunded)
17539
17561
  }
17540
17562
  };
17541
17563
  }
@@ -23288,40 +23310,15 @@ function safeMetadata(metadata) {
23288
23310
  return null;
23289
23311
  }
23290
23312
  }
23291
- async function ensureConcurrencyGateSchema() {
23292
- if (!schemaReady) {
23293
- schemaReady = (async () => {
23294
- const db = getDb();
23295
- await db.execute(`
23296
- CREATE TABLE IF NOT EXISTS concurrency_locks (
23297
- id TEXT PRIMARY KEY,
23298
- user_id INTEGER NOT NULL REFERENCES users(id),
23299
- operation TEXT NOT NULL,
23300
- status TEXT NOT NULL DEFAULT 'active',
23301
- acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
23302
- expires_at TEXT NOT NULL,
23303
- released_at TEXT,
23304
- metadata TEXT
23305
- )
23306
- `);
23307
- await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_user_status ON concurrency_locks(user_id, status, expires_at)`);
23308
- await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_operation ON concurrency_locks(operation, status, expires_at)`);
23309
- })();
23310
- }
23311
- return schemaReady;
23312
- }
23313
23313
  async function expireConcurrencyLocksForUser(userId) {
23314
- await ensureConcurrencyGateSchema();
23315
23314
  await getDb().execute({
23316
- sql: `UPDATE concurrency_locks
23317
- SET status = 'expired', released_at = datetime('now')
23315
+ sql: `DELETE FROM concurrency_locks
23318
23316
  WHERE user_id = ? AND status = 'active' AND expires_at <= datetime('now')`,
23319
23317
  args: [userId]
23320
23318
  });
23321
23319
  }
23322
- async function countActiveUsageForUser(userId) {
23323
- await ensureConcurrencyGateSchema();
23324
- await expireConcurrencyLocksForUser(userId);
23320
+ async function countActiveUsageForUser(userId, alreadyExpired = false) {
23321
+ if (!alreadyExpired) await expireConcurrencyLocksForUser(userId);
23325
23322
  const res = await getDb().execute({
23326
23323
  sql: `SELECT
23327
23324
  (
@@ -23341,7 +23338,6 @@ async function countActiveUsageForUser(userId) {
23341
23338
  }
23342
23339
  async function reuseExistingConcurrencyGate(userId, lockId, ttlSeconds) {
23343
23340
  if (!/^cl_[a-f0-9]{24,32}$/.test(lockId)) return false;
23344
- await ensureConcurrencyGateSchema();
23345
23341
  await expireConcurrencyLocksForUser(userId);
23346
23342
  const res = await getDb().execute({
23347
23343
  sql: `UPDATE concurrency_locks
@@ -23355,13 +23351,12 @@ async function reuseExistingConcurrencyGate(userId, lockId, ttlSeconds) {
23355
23351
  return Number(res.rowsAffected ?? 0) > 0;
23356
23352
  }
23357
23353
  async function acquireConcurrencyGate(user, operation, options = {}) {
23358
- await ensureConcurrencyGateSchema();
23359
23354
  const limit = concurrencyLimitForUser(user);
23360
23355
  if (isConcurrencyLimitBypassed(user)) {
23361
23356
  return { ok: true, lockId: null, active: 0, limit, operation, bypassed: true };
23362
23357
  }
23363
23358
  if (options.reuseLockId && await reuseExistingConcurrencyGate(user.id, options.reuseLockId, options.ttlSeconds)) {
23364
- return { ok: true, lockId: null, active: await countActiveUsageForUser(user.id), limit, operation, reused: true };
23359
+ return { ok: true, lockId: null, active: await countActiveUsageForUser(user.id, true), limit, operation, reused: true };
23365
23360
  }
23366
23361
  await expireConcurrencyLocksForUser(user.id);
23367
23362
  const lockId = `cl_${(0, import_node_crypto10.randomUUID)().replace(/-/g, "").slice(0, 24)}`;
@@ -23392,7 +23387,7 @@ async function acquireConcurrencyGate(user, operation, options = {}) {
23392
23387
  limit
23393
23388
  ]
23394
23389
  });
23395
- const active = await countActiveUsageForUser(user.id);
23390
+ const active = await countActiveUsageForUser(user.id, true);
23396
23391
  if (Number(res.rowsAffected ?? 0) > 0) {
23397
23392
  return { ok: true, lockId, active, limit, operation };
23398
23393
  }
@@ -23400,17 +23395,13 @@ async function acquireConcurrencyGate(user, operation, options = {}) {
23400
23395
  }
23401
23396
  async function releaseConcurrencyGate(lockId) {
23402
23397
  if (!lockId) return;
23403
- await ensureConcurrencyGateSchema();
23404
23398
  await getDb().execute({
23405
- sql: `UPDATE concurrency_locks
23406
- SET status = 'released', released_at = datetime('now')
23407
- WHERE id = ? AND status = 'active'`,
23399
+ sql: `DELETE FROM concurrency_locks WHERE id = ?`,
23408
23400
  args: [lockId]
23409
23401
  });
23410
23402
  }
23411
23403
  async function extendConcurrencyGate(lockId, ttlSeconds = DEFAULT_LOCK_TTL_SECONDS) {
23412
23404
  if (!lockId) return;
23413
- await ensureConcurrencyGateSchema();
23414
23405
  await getDb().execute({
23415
23406
  sql: `UPDATE concurrency_locks
23416
23407
  SET expires_at = datetime('now', ?)
@@ -23418,7 +23409,7 @@ async function extendConcurrencyGate(lockId, ttlSeconds = DEFAULT_LOCK_TTL_SECON
23418
23409
  args: [lockTtlModifier(ttlSeconds), lockId]
23419
23410
  });
23420
23411
  }
23421
- var import_node_crypto10, DEFAULT_LOCK_TTL_SECONDS, DEFAULT_RETRY_AFTER_SECONDS, MAX_LOCK_TTL_SECONDS, schemaReady;
23412
+ var import_node_crypto10, DEFAULT_LOCK_TTL_SECONDS, DEFAULT_RETRY_AFTER_SECONDS, MAX_LOCK_TTL_SECONDS;
23422
23413
  var init_concurrency_gates = __esm({
23423
23414
  "src/api/concurrency-gates.ts"() {
23424
23415
  "use strict";
@@ -23428,7 +23419,6 @@ var init_concurrency_gates = __esm({
23428
23419
  DEFAULT_LOCK_TTL_SECONDS = 15 * 60;
23429
23420
  DEFAULT_RETRY_AFTER_SECONDS = 30;
23430
23421
  MAX_LOCK_TTL_SECONDS = 24 * 60 * 60;
23431
- schemaReady = null;
23432
23422
  }
23433
23423
  });
23434
23424
 
@@ -27530,7 +27520,7 @@ async function recoverSettlement(row, user, authorization, duplicate) {
27530
27520
  }
27531
27521
  }
27532
27522
  await getDb().execute({
27533
- sql: "UPDATE billing_events SET status = 'settled', settled_at = datetime('now') WHERE id = ? AND status = 'settling'",
27523
+ sql: "UPDATE billing_events SET status = 'settled', settled_at = datetime('now') WHERE id = ? AND status IN ('settling', 'cost_pending')",
27534
27524
  args: [row.id]
27535
27525
  });
27536
27526
  await getDb().execute({
@@ -27575,11 +27565,13 @@ async function settleScheduledRun(args) {
27575
27565
  const rawCostNanos = rawCostUsd == null ? null : Math.round(rawCostUsd * USD_NANOS);
27576
27566
  const modelMc = rawCostUsd == null ? 0 : vendorCostUsdToMc(rawCostUsd, SCHEDULED_AGENT_LLM_MARKUP_BPS);
27577
27567
  const maximumModelMc = Math.max(0, Number(authorization.maximum_amount_mc) - SCHEDULED_RUN_BASE_MC);
27578
- const pendingReason = args.modelCostStatus === "missing" ? "openrouter_cost_missing" : modelMc > maximumModelMc ? "max_credits_per_run_exceeded" : null;
27568
+ const modelCostUnreported = args.modelCostStatus === "missing";
27569
+ const pendingReason = modelMc > maximumModelMc ? "max_credits_per_run_exceeded" : null;
27579
27570
  const metadata = safeMetadata2({
27580
27571
  ...args.metadata,
27581
27572
  billingPhase: "settlement",
27582
27573
  chargeMode: "model_delta",
27574
+ ...modelCostUnreported ? { modelCostUnreported: true } : {},
27583
27575
  ...pendingReason ? { reason: pendingReason } : {}
27584
27576
  });
27585
27577
  const eventId = existing?.id ?? (0, import_node_crypto14.randomUUID)();
@@ -27623,6 +27615,72 @@ async function settleScheduledRun(args) {
27623
27615
  }
27624
27616
  return recoverSettlement(existing, user, authorization, duplicateClaim);
27625
27617
  }
27618
+ async function reconcileScheduledRunSettlements(limit = 25, minimumAgeHours = 24) {
27619
+ const stale = await getDb().execute({
27620
+ sql: `SELECT id, authorization_id, status, amount_mc, raw_cost_usd_nanos, multiplier_bps, metadata
27621
+ FROM billing_events
27622
+ WHERE billing_class = ? AND status = 'cost_pending'
27623
+ AND created_at < datetime('now', ?)
27624
+ ORDER BY created_at
27625
+ LIMIT ?`,
27626
+ args: [SCHEDULED_RUN_BILLING_CLASS, `-${Math.max(1, Math.trunc(minimumAgeHours))} hours`, Math.max(1, Math.trunc(limit))]
27627
+ });
27628
+ const result = { checked: stale.rows.length, settled: 0, chargedMc: 0, failed: 0 };
27629
+ for (const raw of stale.rows) {
27630
+ const row = raw;
27631
+ try {
27632
+ const authorization = await authorizationById(row.authorization_id);
27633
+ if (!authorization) {
27634
+ result.failed += 1;
27635
+ continue;
27636
+ }
27637
+ const cap = Math.max(0, Number(authorization.maximum_amount_mc) - SCHEDULED_RUN_BASE_MC);
27638
+ const chargeMc = Math.min(Math.max(0, Math.round(Number(row.amount_mc))), cap);
27639
+ if (chargeMc > 0) {
27640
+ const debit = await debitMcIdempotent(
27641
+ Number(authorization.user_id),
27642
+ chargeMc,
27643
+ LedgerOperation.SCHEDULED_RUN,
27644
+ "scheduled-run:" + authorization.id + ":model:reconciled",
27645
+ authorization.id + ":model"
27646
+ );
27647
+ if (!debit.ok) {
27648
+ result.failed += 1;
27649
+ continue;
27650
+ }
27651
+ }
27652
+ await getDb().execute({
27653
+ sql: `UPDATE billing_events
27654
+ SET status = 'settled', amount_mc = ?, settled_at = datetime('now'), metadata = ?
27655
+ WHERE id = ? AND status = 'cost_pending'`,
27656
+ args: [
27657
+ chargeMc,
27658
+ safeMetadata2({ ...metadataReason(row) ? { reason: metadataReason(row) } : {}, autoReconciled: true, cappedAtAuthorizedMaximum: chargeMc < Number(row.amount_mc) }),
27659
+ row.id
27660
+ ]
27661
+ });
27662
+ await getDb().execute({
27663
+ sql: "UPDATE billing_authorizations SET status = 'settled', settled_at = datetime('now') WHERE id = ? AND status IN ('started', 'cost_pending')",
27664
+ args: [authorization.id]
27665
+ });
27666
+ result.settled += 1;
27667
+ result.chargedMc += chargeMc;
27668
+ console.error(JSON.stringify({
27669
+ event: "scheduled_run_settlement_reconciled",
27670
+ authorization_id: authorization.id,
27671
+ charged_mc: chargeMc,
27672
+ reason: metadataReason(row) ?? "unreported_cost"
27673
+ }));
27674
+ } catch (err) {
27675
+ result.failed += 1;
27676
+ console.error(JSON.stringify({
27677
+ event: "scheduled_run_reconciliation_failed",
27678
+ message: err instanceof Error ? err.message : String(err)
27679
+ }));
27680
+ }
27681
+ }
27682
+ return result;
27683
+ }
27626
27684
  async function voidScheduledRunAuthorization(args) {
27627
27685
  const user = await resolveSchedulingUser(args.identity);
27628
27686
  const authorization = await authorizationById(args.authorizationId);
@@ -28609,28 +28667,6 @@ var init_inbox_routes = __esm({
28609
28667
  });
28610
28668
 
28611
28669
  // src/api/connected-account-billing.ts
28612
- async function ensureConnectedAccountBillingSchema() {
28613
- await getDb().execute(`CREATE TABLE IF NOT EXISTS connected_account_billing (
28614
- user_id INTEGER PRIMARY KEY,
28615
- stripe_subscription_id TEXT,
28616
- stripe_subscription_item_id TEXT,
28617
- price_id TEXT,
28618
- quantity INTEGER NOT NULL DEFAULT 0,
28619
- status TEXT NOT NULL DEFAULT 'pending',
28620
- last_error_code TEXT,
28621
- checked_at TEXT NOT NULL DEFAULT (datetime('now')),
28622
- synced_at TEXT,
28623
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
28624
- )`);
28625
- await getDb().execute(`CREATE TABLE IF NOT EXISTS connected_account_charges (
28626
- user_id INTEGER NOT NULL,
28627
- connection_id TEXT NOT NULL,
28628
- period_start TEXT NOT NULL,
28629
- amount_mc INTEGER NOT NULL,
28630
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
28631
- PRIMARY KEY (user_id, connection_id, period_start)
28632
- )`);
28633
- }
28634
28670
  function connectedBillingPeriodStart(now = /* @__PURE__ */ new Date()) {
28635
28671
  return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
28636
28672
  }
@@ -28638,7 +28674,6 @@ function isBillableConnection(connection) {
28638
28674
  return BILLABLE_LIFECYCLE.has(String(connection.lifecycleStatus ?? ""));
28639
28675
  }
28640
28676
  async function chargedConnectionIds(userId, periodStart) {
28641
- await ensureConnectedAccountBillingSchema();
28642
28677
  const result = await getDb().execute({
28643
28678
  sql: "SELECT connection_id FROM connected_account_charges WHERE user_id = ? AND period_start = ?",
28644
28679
  args: [Number(userId), periodStart]
@@ -28646,7 +28681,6 @@ async function chargedConnectionIds(userId, periodStart) {
28646
28681
  return new Set(result.rows.map((row) => String(row.connection_id)));
28647
28682
  }
28648
28683
  async function getConnectedAccountBillingState2(userId) {
28649
- await ensureConnectedAccountBillingSchema();
28650
28684
  const result = await getDb().execute({
28651
28685
  sql: "SELECT * FROM connected_account_billing WHERE user_id = ? LIMIT 1",
28652
28686
  args: [Number(userId)]
@@ -28654,7 +28688,6 @@ async function getConnectedAccountBillingState2(userId) {
28654
28688
  return result.rows[0] ? result.rows[0] : null;
28655
28689
  }
28656
28690
  async function setConnectedAccountBillingState2(input) {
28657
- await ensureConnectedAccountBillingSchema();
28658
28691
  await getDb().execute({
28659
28692
  sql: `INSERT INTO connected_account_billing
28660
28693
  (user_id, stripe_subscription_id, stripe_subscription_item_id, price_id, quantity, status, last_error_code, checked_at, synced_at, updated_at)
@@ -33186,6 +33219,15 @@ var init_instagram_routes = __esm({
33186
33219
  });
33187
33220
 
33188
33221
  // src/api/reddit-trending.ts
33222
+ function buildRedditSearchUrl(topic, subreddit, window2 = "month") {
33223
+ const base = subreddit ? `https://old.reddit.com/r/${encodeURIComponent(subreddit)}/search` : "https://old.reddit.com/search";
33224
+ const params = new URLSearchParams();
33225
+ params.set("q", topic);
33226
+ params.set("sort", "top");
33227
+ params.set("t", window2);
33228
+ if (subreddit) params.set("restrict_sr", "on");
33229
+ return `${base}?${params.toString()}`;
33230
+ }
33189
33231
  function buildSerpQuery(topic, subreddit) {
33190
33232
  const scope = subreddit ? `site:reddit.com/r/${subreddit}` : "site:reddit.com";
33191
33233
  return `${scope} ${topic}`.trim();
@@ -33248,6 +33290,32 @@ function canonicalThreadUrl(href) {
33248
33290
  function engagementScore(score, commentCount) {
33249
33291
  return score + 2 * commentCount;
33250
33292
  }
33293
+ function normalizeSearchRows(rows) {
33294
+ if (!Array.isArray(rows)) return [];
33295
+ const seen = /* @__PURE__ */ new Set();
33296
+ const out = [];
33297
+ for (const raw of rows) {
33298
+ if (!raw || typeof raw !== "object") continue;
33299
+ const r = raw;
33300
+ const url = canonicalThreadUrl(r.url);
33301
+ const title = typeof r.title === "string" ? r.title.trim() : "";
33302
+ if (!url || !title || seen.has(url)) continue;
33303
+ seen.add(url);
33304
+ const score = parseCountText(r.scoreText);
33305
+ const commentCount = parseCountText(r.commentsText);
33306
+ out.push({
33307
+ title,
33308
+ url,
33309
+ subreddit: typeof r.subreddit === "string" ? r.subreddit.trim() : "",
33310
+ score,
33311
+ commentCount,
33312
+ engagementScore: engagementScore(score, commentCount),
33313
+ ageText: typeof r.ageText === "string" ? r.ageText.trim() : "",
33314
+ topQuestions: []
33315
+ });
33316
+ }
33317
+ return out;
33318
+ }
33251
33319
  function normalizeQuestionKey(s) {
33252
33320
  return s.toLowerCase().replace(/[?.!\s]+$/g, "").replace(/\s+/g, " ").trim();
33253
33321
  }
@@ -33298,10 +33366,29 @@ function flattenQuestions(threads) {
33298
33366
  }
33299
33367
  return out;
33300
33368
  }
33301
- var INTERROGATIVE_STARTERS;
33369
+ var PARSE_REDDIT_SEARCH, INTERROGATIVE_STARTERS;
33302
33370
  var init_reddit_trending = __esm({
33303
33371
  "src/api/reddit-trending.ts"() {
33304
33372
  "use strict";
33373
+ PARSE_REDDIT_SEARCH = `(() => {
33374
+ const txt = el => ((el && el.innerText) || '').trim();
33375
+ const bodyText = (document.body && document.body.innerText) || '';
33376
+ const blocked = /whoa there|blocked by network|you've been blocked|network (policy|security)|log in to your reddit account/i.test(bodyText);
33377
+ const listing = document.querySelector('.search-result-listing');
33378
+ const rows = [];
33379
+ document.querySelectorAll('.search-result.search-result-link').forEach(r => {
33380
+ const titleEl = r.querySelector('header.search-result-header a.search-title');
33381
+ rows.push({
33382
+ title: txt(titleEl),
33383
+ url: (titleEl && titleEl.getAttribute('href')) || '',
33384
+ subreddit: txt(r.querySelector('a.search-subreddit-link')),
33385
+ scoreText: txt(r.querySelector('.search-score')),
33386
+ commentsText: txt(r.querySelector('a.search-comments')),
33387
+ ageText: txt(r.querySelector('.search-time time')),
33388
+ });
33389
+ });
33390
+ return { blocked: blocked, listingFound: Boolean(listing), rowCount: rows.length, rows: rows };
33391
+ })()`;
33305
33392
  INTERROGATIVE_STARTERS = /* @__PURE__ */ new Set([
33306
33393
  "what",
33307
33394
  "whats",
@@ -35499,8 +35586,8 @@ async function residentialProxyId(attemptIndex) {
35499
35586
  return void 0;
35500
35587
  }
35501
35588
  }
35502
- async function scrapeOldRedditWithRetries(url, script, accept) {
35503
- for (let attempt = 0; attempt < 4; attempt++) {
35589
+ async function scrapeOldRedditWithRetries(url, script, accept, maxAttempts = 4) {
35590
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
35504
35591
  const backupAttempt = attempt === 3;
35505
35592
  if (backupAttempt && !backupProxyAvailable()) break;
35506
35593
  const proxyId = backupAttempt ? await createBackupProxyIdSafe(browserServiceApiKey(), attempt) : await residentialProxyId(attempt);
@@ -35669,6 +35756,9 @@ var init_reddit_routes = __esm({
35669
35756
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.reddit_thread), 402);
35670
35757
  discoveryDebited = true;
35671
35758
  let candidates = [];
35759
+ let discoverySource = "google_serp";
35760
+ let primaryDiscoveryDegraded = false;
35761
+ let primaryDegradationReasons = [];
35672
35762
  try {
35673
35763
  const serp = await harvest({
35674
35764
  query: serpQuery,
@@ -35682,14 +35772,59 @@ var init_reddit_routes = __esm({
35682
35772
  softDeadlineMs
35683
35773
  });
35684
35774
  candidates = extractRedditThreadUrls(serp.organicResults, body.maxThreads);
35775
+ primaryDiscoveryDegraded = serp.diagnostics?.degradedResult === true;
35776
+ primaryDegradationReasons = Array.isArray(serp.diagnostics?.degradationReasons) ? serp.diagnostics.degradationReasons.filter((reason) => typeof reason === "string") : [];
35685
35777
  } catch {
35686
35778
  candidates = [];
35779
+ primaryDiscoveryDegraded = true;
35780
+ primaryDegradationReasons = ["primary_serp_failed"];
35781
+ }
35782
+ if (candidates.length === 0) {
35783
+ const fallback = await scrapeOldRedditWithRetries(
35784
+ buildRedditSearchUrl(body.topic, subreddit, body.window),
35785
+ PARSE_REDDIT_SEARCH,
35786
+ (data) => !data.blocked && data.listingFound,
35787
+ 2
35788
+ );
35789
+ if (fallback) {
35790
+ candidates = normalizeSearchRows(fallback.data.rows).slice(0, body.maxThreads).map((thread) => ({ title: thread.title, url: thread.url, subreddit: thread.subreddit.replace(/^r\//i, "") }));
35791
+ discoverySource = "reddit_search_fallback";
35792
+ } else {
35793
+ discoverySource = "none";
35794
+ }
35687
35795
  }
35688
35796
  if (candidates.length === 0) {
35689
35797
  await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "no reddit threads discovered");
35690
35798
  discoveryRefunded = true;
35691
- await logRequestEvent({ userId: user.id, source: "reddit_trending", status: "failed", query: body.topic, error: "no reddit threads discovered" });
35692
- return c.json({ error: "No Reddit threads found for that topic and window (refunded)" }, 404);
35799
+ const degradedResult = discoverySource === "none";
35800
+ const degradationReasons = degradedResult ? [.../* @__PURE__ */ new Set([...primaryDegradationReasons, "reddit_fallback_empty_or_blocked"])] : [];
35801
+ const result2 = {
35802
+ topic: body.topic,
35803
+ subreddit: subreddit ?? null,
35804
+ window: body.window === "week" ? "7d" : "30d",
35805
+ totals: trendingTotals([]),
35806
+ rankedThreads: [],
35807
+ questions: [],
35808
+ threadsScraped: 0,
35809
+ candidatesFound: 0,
35810
+ partial: false,
35811
+ searchQuery: serpQuery,
35812
+ discoverySource,
35813
+ resultQuality: degradedResult ? "degraded" : "complete",
35814
+ degradedResult,
35815
+ degradationReasons,
35816
+ retryRecommended: degradedResult,
35817
+ billingRefunded: true
35818
+ };
35819
+ await logRequestEvent({
35820
+ userId: user.id,
35821
+ source: "reddit_trending",
35822
+ status: "done",
35823
+ query: body.topic,
35824
+ resultCount: 0,
35825
+ result: result2
35826
+ });
35827
+ return c.json(result2);
35693
35828
  }
35694
35829
  let ranked;
35695
35830
  if (!body.includeComments) {
@@ -35765,7 +35900,13 @@ var init_reddit_routes = __esm({
35765
35900
  threadsScraped: body.includeComments ? ranked.length : 0,
35766
35901
  candidatesFound: candidates.length,
35767
35902
  partial: body.includeComments && ranked.length < candidates.length,
35768
- searchQuery: serpQuery
35903
+ searchQuery: serpQuery,
35904
+ discoverySource,
35905
+ resultQuality: primaryDiscoveryDegraded ? "partial" : "complete",
35906
+ degradedResult: false,
35907
+ degradationReasons: primaryDiscoveryDegraded ? primaryDegradationReasons : [],
35908
+ retryRecommended: false,
35909
+ billingRefunded: false
35769
35910
  };
35770
35911
  await logRequestEvent({ userId: user.id, source: "reddit_trending", status: "done", query: body.topic, resultCount: ranked.length, result });
35771
35912
  return c.json(result);
@@ -37670,6 +37811,89 @@ var init_paa_harvest_reconciliation = __esm({
37670
37811
  }
37671
37812
  });
37672
37813
 
37814
+ // src/api/retention-sweeps.ts
37815
+ function cutoffFor(rule) {
37816
+ const cutoff = new Date(Date.now() - rule.retentionDays * 24 * 60 * 60 * 1e3);
37817
+ if (rule.timestampFormat === "iso8601") return cutoff.toISOString();
37818
+ return cutoff.toISOString().replace("T", " ").slice(0, 19);
37819
+ }
37820
+ async function tableExists(table) {
37821
+ const res = await getDb().execute({
37822
+ sql: "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ? LIMIT 1",
37823
+ args: [table]
37824
+ });
37825
+ return res.rows.length > 0;
37826
+ }
37827
+ async function sweepRule(rule, maxDeletes) {
37828
+ if (!await tableExists(rule.table)) return 0;
37829
+ const filter = rule.where ? ` AND ${rule.where}` : "";
37830
+ const cutoff = cutoffFor(rule);
37831
+ let deleted = 0;
37832
+ while (deleted < maxDeletes) {
37833
+ const res = await getDb().execute({
37834
+ sql: `DELETE FROM ${rule.table} WHERE rowid IN (
37835
+ SELECT rowid FROM ${rule.table}
37836
+ WHERE ${rule.timestampColumn} < ?${filter}
37837
+ LIMIT ?)`,
37838
+ args: [cutoff, Math.min(DEFAULT_BATCH, maxDeletes - deleted)]
37839
+ });
37840
+ const n = Number(res.rowsAffected ?? 0);
37841
+ deleted += n;
37842
+ if (n === 0) break;
37843
+ }
37844
+ return deleted;
37845
+ }
37846
+ async function sweepRetention(options = {}) {
37847
+ const maxDeletes = options.maxDeletesPerRule ?? DEFAULT_MAX_DELETES_PER_RULE;
37848
+ const deadline = Date.now() + (options.deadlineMs ?? DEFAULT_SWEEP_DEADLINE_MS);
37849
+ const perTable = {};
37850
+ let deleted = 0;
37851
+ let truncated = false;
37852
+ for (const rule of RETENTION_RULES) {
37853
+ const key = rule.where ? `${rule.table}:${rule.where}` : rule.table;
37854
+ if (Date.now() >= deadline) {
37855
+ truncated = true;
37856
+ break;
37857
+ }
37858
+ try {
37859
+ const n = await sweepRule(rule, maxDeletes);
37860
+ perTable[key] = n;
37861
+ deleted += n;
37862
+ if (n >= maxDeletes) truncated = true;
37863
+ } catch (err) {
37864
+ perTable[key] = -1;
37865
+ console.error(JSON.stringify({
37866
+ event: "retention_sweep_failed",
37867
+ table: rule.table,
37868
+ message: err instanceof Error ? err.message : String(err)
37869
+ }));
37870
+ }
37871
+ }
37872
+ return { deleted, perTable, truncated };
37873
+ }
37874
+ var REPORTING_WINDOW_DAYS, TELEMETRY_RETENTION_DAYS, FAILURE_RETENTION_DAYS, RETENTION_RULES, DEFAULT_BATCH, DEFAULT_MAX_DELETES_PER_RULE, DEFAULT_SWEEP_DEADLINE_MS;
37875
+ var init_retention_sweeps = __esm({
37876
+ "src/api/retention-sweeps.ts"() {
37877
+ "use strict";
37878
+ init_db();
37879
+ REPORTING_WINDOW_DAYS = 30;
37880
+ TELEMETRY_RETENTION_DAYS = 45;
37881
+ FAILURE_RETENTION_DAYS = 90;
37882
+ RETENTION_RULES = [
37883
+ { table: "request_events", timestampColumn: "created_at", retentionDays: TELEMETRY_RETENTION_DAYS },
37884
+ { table: "kernel_session_log", timestampColumn: "created_at", retentionDays: TELEMETRY_RETENTION_DAYS },
37885
+ { table: "vendor_usage_log", timestampColumn: "created_at", retentionDays: TELEMETRY_RETENTION_DAYS },
37886
+ { table: "harvest_attempts", timestampColumn: "created_at", retentionDays: TELEMETRY_RETENTION_DAYS, timestampFormat: "iso8601" },
37887
+ { table: "browser_agent_actions", timestampColumn: "created_at", retentionDays: REPORTING_WINDOW_DAYS },
37888
+ { table: "jobs", timestampColumn: "created_at", retentionDays: TELEMETRY_RETENTION_DAYS, where: "status = 'done'" },
37889
+ { table: "jobs", timestampColumn: "created_at", retentionDays: FAILURE_RETENTION_DAYS, where: "status = 'failed'" }
37890
+ ];
37891
+ DEFAULT_BATCH = 500;
37892
+ DEFAULT_MAX_DELETES_PER_RULE = 1e3;
37893
+ DEFAULT_SWEEP_DEADLINE_MS = 15e3;
37894
+ }
37895
+ });
37896
+
37673
37897
  // src/api/directory-workflow-reconciliation.ts
37674
37898
  async function reconcileDirectoryWorkflowSettlements(limit = 25) {
37675
37899
  const expiredStarts = await listExpiredDirectoryWorkflowStarts(limit);
@@ -41371,7 +41595,7 @@ var PACKAGE_VERSION;
41371
41595
  var init_version = __esm({
41372
41596
  "src/version.ts"() {
41373
41597
  "use strict";
41374
- PACKAGE_VERSION = "0.49.0";
41598
+ PACKAGE_VERSION = "0.51.0";
41375
41599
  }
41376
41600
  });
41377
41601
 
@@ -41520,7 +41744,10 @@ seam is noted so you can chain them.
41520
41744
  handles Reddit's bot wall itself (no login needed). Find threads first with \`search_serp\` or \`reddit_trending\`.
41521
41745
  - DISCOVER what a niche is talking about (topic, no known thread) -> **reddit_trending** (takes a topic, optional
41522
41746
  subreddit; returns the last 30 days' top threads ranked by engagement plus the questions people asked \u2014
41523
- feed winning \`rankedThreads[].url\` values into \`reddit_thread\` for the full comment tree).
41747
+ feed winning \`rankedThreads[].url\` values into \`reddit_thread\` for the full comment tree). It uses bounded
41748
+ direct Reddit discovery when the primary SERP is empty. Before interpreting an empty result, inspect
41749
+ \`resultQuality\`, \`discoverySource\`, \`degradationReasons\`, \`retryRecommended\`, and \`billingRefunded\`;
41750
+ a degraded empty result is not evidence that the topic has no Reddit discussion.
41524
41751
 
41525
41752
  ## Other sites & logins (browser agent)
41526
41753
  For an arbitrary site or a logged-in dashboard with no dedicated tool, use the browser_* agent. **First
@@ -41570,6 +41797,12 @@ Multi-step orchestrations \u2014 prefer these over hand-chaining primitives when
41570
41797
  versioning reusable website templates; use the saved-template tools above for those jobs.
41571
41798
  - The creation tool is a renderer, not a research or writing model. Preserve source truth in each
41572
41799
  \`sourceLabel\`; do not hand it raw source material and expect it to invent the editorial architecture.
41800
+ - One edition may contain up to 100 articles. Images may appear as structured article/card heroes or as
41801
+ Markdown body images. Use \`site.ogImage\` for the collection social preview and \`article.ogImage\` for an
41802
+ article override; otherwise the article hero, then collection image, is reused. Every image requires useful
41803
+ alt text. Preserve caption, credit, source URL, and rights context when known; a reachable URL does not prove
41804
+ reuse rights. Static artifacts include collection OG tags and update article tags in-browser, while crawler-
41805
+ perfect per-article unfurls require the publishing host to serve article-specific metadata.
41573
41806
  - ${savesReportsLocally ? "This local stdio server writes one self-contained HTML file and returns its localPath so the user can open it." : "This hosted server creates a private seven-day HTML artifact and returns a signed download URL; use renew_editorial_reading_room_download after the URL expires."}
41574
41807
 
41575
41808
  ## Local Sourcebook listings
@@ -41661,6 +41894,17 @@ deliberate edits and migrations. Use
41661
41894
  they'll filter/sort by exact value. **memory-search** is meaning-based over full content (embeds, slower);
41662
41895
  **memory-list** filters one vault by kind/tags (fast, exact).
41663
41896
 
41897
+ Choose the read surface by completeness, not convenience. **list-vaults** is the account inventory and
41898
+ reports each vault's note count. **memory-list** returns the complete metadata inventory for one vault but
41899
+ no note bodies, plus a sorted complete folder inventory derived from those paths; use it to identify every
41900
+ stable \`vault + path\` address. **list-memory-tags** returns the
41901
+ complete account-wide canonical tag inventory, including aliases, usage counts, and per-vault distribution.
41902
+ **memory-search** returns ranked
41903
+ content chunks, not an exhaustive inventory or complete documents: deduplicate hits by vault/path and call
41904
+ **memory-get** for every note whose full meaning matters. When the user explicitly needs every full note for
41905
+ backup, migration, or corpus-wide audit, use **memory-export** one vault at a time; do not pull the entire
41906
+ corpus merely to edit one note.
41907
+
41664
41908
  For People, Deals, Projects, Tasks, and Communications, use the returned contract rather than guessing:
41665
41909
  People only holds a real person or organization hub. Its contact card uses \`phone\`, \`text_phone\`, and
41666
41910
  \`email\` for Call/Text/Email; \`memories\` for durable person context; and linked Deals, Projects,
@@ -41719,19 +41963,35 @@ Tags are live vocabulary, not improvised labels: **list-memory-tags** shows what
41719
41963
  reusable, and has no exact, alias, or near-equivalent. Use **memory-backlinks**,
41720
41964
  **memory-graph-universe**, and **memory-graph-path** to trace the linked universe across vaults.
41721
41965
 
41722
- **Always inspect the complete tag inventory and related notes first.** Use hybrid Smart RAG by default:
41966
+ **Always inspect the complete tag inventory and related notes first.** When the user wants to find, recall,
41967
+ understand, or connect something and does not already provide an exact vault/path, start with hybrid Smart
41968
+ RAG through **memory-search**. The exceptions are exhaustive inventory (**memory-list**), title-only lookup
41969
+ (**memory-suggest**), an exact known note (**memory-get**), and an explicit full-vault export (**memory-export**).
41970
+ For hybrid retrieval,
41723
41971
  form 3 focused queries (2\u20134 when useful), fuse exact tag/metadata/vault/date and semantic matches into 50
41724
41972
  candidates, expand the top 8 seeds by one link/backlink hop with at most 5 neighbors each, then Jina-rerank
41725
41973
  the combined pool to the best 30. Graph neighbors are candidates, never automatic links; add only links
41726
- supported by the note contents.
41974
+ supported by the note contents. A search hit is a discovery excerpt, not the note: call **memory-get** and read
41975
+ the complete note before relying on it for an answer, summary, edit, relationship, or durable write. Read the
41976
+ strong candidates, not every low-ranked hit.
41727
41977
 
41728
41978
  Scrape deposits are raw evidence and therefore go to **Library** through **library-ingest**, with the full
41729
41979
  Library template and source metadata. If the source contains durable applicable guidance, create a separate
41730
41980
  Knowledge companion through prepare-memory-write + memory-capture and link it to the Library source with
41731
41981
  derived_from; do not replace the raw source with the guide.
41732
41982
 
41733
- For an update, call **memory-get** first and pass its revision as baseRevision. Preserve the existing
41734
- title, source, capture time, content context, and props unless the request deliberately changes them.
41983
+ For an update, identify the existing note by its stable \`vault + path\`, call **memory-get** first, and pass
41984
+ its revision as \`baseRevision\`. **memory-put replaces the entire content body; it is not a text patch.**
41985
+ Merge the requested change into the full body returned by memory-get, then send that complete merged body.
41986
+ Its supplied \`props\` patch existing metadata, so omit unchanged props and use an empty array only when the
41987
+ user deliberately wants to clear a link list. Preserve the existing title, source, capture time, surrounding
41988
+ content, links, template sections, and unsupported uncertainty unless the request deliberately changes them.
41989
+ A title change does not create a new note identity; keep the path unless the user explicitly asks to move it.
41990
+ Never convert an edit into a new path just because search returned a similar title. If \`baseRevision\`
41991
+ conflicts, reconcile the returned current body with the requested change and retry against the new revision;
41992
+ do not resend the stale full body unchanged. Treat Agent Inbox, optimizer rollups, channel messages, and other
41993
+ system-managed notes through their purpose-built tools when available rather than rewriting their backing
41994
+ notes with memory-put.
41735
41995
  If a legacy backend does not return props, do not overwrite an existing linked note through that surface;
41736
41996
  report that link preservation cannot be proved. After the write, read it back and search a distinctive
41737
41997
  phrase to verify persistence and indexing.
@@ -42567,7 +42827,7 @@ var init_contracts = __esm({
42567
42827
  });
42568
42828
 
42569
42829
  // src/mcp/mcp-tool-schemas.ts
42570
- var import_zod42, WEBSITE_URL_OR_DOMAIN_ERROR, WebsiteUrlOrDomainSchema, HarvestPaaInputSchema, ExtractUrlBaseInputSchema, ExtractUrlInputSchema, ExtractUrlLocalInputSchema, DiffPageBaseInputSchema, DiffPageInputSchema, DiffPageLocalInputSchema, MapSiteUrlsInputSchema, MapWaybackSnapshotsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, CheckSiteExportInputSchema, ArchiveReadInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, RedditThreadInputSchema, RedditTrendingInputSchema, VideoFrameAnalysisInputSchema, VideoFrameAnalysisStatusInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, GoogleAdsSearchInputSchema, GoogleAdsPageIntelInputSchema, GoogleAdsTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, TrustpilotReviewsInputSchema, G2ReviewsInputSchema, ReviewCardSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, LocationMarketsInputSchema, CommonsSearchEntitiesInputSchema, CommonsGetEntityInputSchema, CommonsGetEntityLinksetInputSchema, CommonsFeaturedImageInputSchema, CommonsMediaInputSchema, CommonsCitationInputSchema, CommonsSourceInputSchema, CommonsRelatedLinkInputSchema, CommonsClaimInputSchema, CommonsPrepareEntityInputSchema, CommonsSubmitEntityInputSchema, CommonsValidateEntityInputSchema, CommonsGetEntityLedgerInputSchema, CommonsSaveFilterInputSchema, CommonsListFiltersInputSchema, CommonsListNeedsLinksInputSchema, CommonsGenericOutputSchema, DirectoryWorkflowStatusInputSchema, LocalSourcebookSubmitInputSchema, LocalSourcebookCategorySchema, LocalSourcebookSchemaTypeInputSchema, LocalSourcebookTagCandidateObjectSchema, LocalSourcebookTagDecisionObjectSchema, LocalSourcebookIdentityObjectSchema, GetLocalSourcebookContractInputSchema, ListLocalSourcebookTagsInputSchema, ResolveLocalSourcebookTagsInputSchema, PrepareLocalSourcebookWriteInputSchema, ValidateLocalSourcebookWriteInputSchema, LocalSourcebookCaptureInputSchema, LocalSourcebookSubmissionStatusInputSchema, LocalSourcebookRefreshInputSchema, LocalSourcebookOutputSchema, ArtifactPointerOutputSchema, EditorialReadingRoomSiteSchema, EditorialReadingRoomArticleSchema, EditorialReadingRoomGuideInputSchema, EditorialReadingRoomGuideOutputSchema, CreateEditorialReadingRoomInputSchema, EditorialReadingRoomArtifactSchema, CreateEditorialReadingRoomOutputSchema, RenewEditorialReadingRoomDownloadInputSchema, RenewEditorialReadingRoomDownloadOutputSchema, CommonsPublicationSubdomainSchema, CommonsPreparePublicationInputSchema, CommonsValidatePublicationInputSchema, CommonsClaimPublicationInputSchema, CommonsPublishEditorialInputSchema, CommonsGetPublicationInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryCsvArtifactOutput, DirectoryWorkflowOutputSchema, LocationDatasetProvenanceOutput, LocationMarketsOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, DiffPageOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, CheckSiteExportOutputSchema, ArchiveEntryOutputSchema, ArchiveReadOutputSchema, MapsPlaceIntelOutputSchema, TrustpilotReviewsOutputSchema, G2ReviewsOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, WaybackCaptureOutputSchema, MapWaybackSnapshotsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, VideoFrameAnalysisOutputSchema, VideoFrameAnalysisStatusOutputSchema, RedditThreadOutputSchema, RedditTrendingOutputSchema, FacebookPageIntelOutputSchema, GoogleAdsSearchOutputSchema, GoogleAdsPageIntelOutputSchema, TranscriptSignalOutput, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, GoogleAdsTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema, ReportArtifactReadInputSchema, ReportArtifactReadOutputSchema, ListServiceConnectionsInputSchema, ListServiceConnectionsOutputSchema, TestServiceConnectionInputSchema, TestServiceConnectionOutputSchema, ReadServiceConnectionInputSchema, ReadServiceConnectionOutputSchema, MetaAdCreativeMediaInputSchema, MetaAdCreativeMediaOutputSchema, ImportServiceConnectionToMemoryInputSchema, ImportServiceConnectionToMemoryOutputSchema, DescribeServiceConnectionToolInputSchema, DescribeServiceConnectionToolOutputSchema, ConnectedDataContinuationSchema, ExportConnectedServiceDataInputSchema, ConnectedDataArtifactSchema, ExportConnectedServiceDataOutputSchema, SearchConsoleTableColumnSchema, SearchConsoleTableFilterSchema, ExportSearchConsoleTableDataInputSchema, ExportSearchConsoleTableDataOutputSchema, RenewConnectedDataExportDownloadInputSchema, RenewConnectedDataExportDownloadOutputSchema, CallServiceConnectionActionInputSchema, CallServiceConnectionActionOutputSchema, SetScheduledActionConnectionsInputSchema, SetScheduledActionConnectionsOutputSchema, SlackSendMessageInputSchema, SlackSendMessageOutputSchema, GmailSendMessageInputSchema, GmailSendMessageOutputSchema, GmailSearchContactsInputSchema, GmailSearchContactsOutputSchema, GoogleCalendarCreateEventInputSchema, GoogleCalendarCreateEventOutputSchema, ZoomCreateMeetingInputSchema, ZoomCreateMeetingOutputSchema;
42830
+ var import_zod42, WEBSITE_URL_OR_DOMAIN_ERROR, WebsiteUrlOrDomainSchema, HarvestPaaInputSchema, ExtractUrlBaseInputSchema, ExtractUrlInputSchema, ExtractUrlLocalInputSchema, DiffPageBaseInputSchema, DiffPageInputSchema, DiffPageLocalInputSchema, MapSiteUrlsInputSchema, MapWaybackSnapshotsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, CheckSiteExportInputSchema, ArchiveReadInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, RedditThreadInputSchema, RedditTrendingInputSchema, VideoFrameAnalysisInputSchema, VideoFrameAnalysisStatusInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, GoogleAdsSearchInputSchema, GoogleAdsPageIntelInputSchema, GoogleAdsTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, TrustpilotReviewsInputSchema, G2ReviewsInputSchema, ReviewCardSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, LocationMarketsInputSchema, CommonsSearchEntitiesInputSchema, CommonsGetEntityInputSchema, CommonsGetEntityLinksetInputSchema, CommonsFeaturedImageInputSchema, CommonsMediaInputSchema, CommonsCitationInputSchema, CommonsSourceInputSchema, CommonsRelatedLinkInputSchema, CommonsClaimInputSchema, CommonsPrepareEntityInputSchema, CommonsSubmitEntityInputSchema, CommonsValidateEntityInputSchema, CommonsGetEntityLedgerInputSchema, CommonsSaveFilterInputSchema, CommonsListFiltersInputSchema, CommonsListNeedsLinksInputSchema, CommonsGenericOutputSchema, DirectoryWorkflowStatusInputSchema, LocalSourcebookSubmitInputSchema, LocalSourcebookCategorySchema, LocalSourcebookSchemaTypeInputSchema, LocalSourcebookTagCandidateObjectSchema, LocalSourcebookTagDecisionObjectSchema, LocalSourcebookIdentityObjectSchema, GetLocalSourcebookContractInputSchema, ListLocalSourcebookTagsInputSchema, ResolveLocalSourcebookTagsInputSchema, PrepareLocalSourcebookWriteInputSchema, ValidateLocalSourcebookWriteInputSchema, LocalSourcebookCaptureInputSchema, LocalSourcebookSubmissionStatusInputSchema, LocalSourcebookRefreshInputSchema, LocalSourcebookOutputSchema, ArtifactPointerOutputSchema, EditorialReadingRoomSiteSchema, EditorialReadingRoomImageSchema, EditorialReadingRoomArticleSchema, EditorialReadingRoomGuideInputSchema, EditorialReadingRoomGuideOutputSchema, CreateEditorialReadingRoomInputSchema, EditorialReadingRoomArtifactSchema, CreateEditorialReadingRoomOutputSchema, RenewEditorialReadingRoomDownloadInputSchema, RenewEditorialReadingRoomDownloadOutputSchema, CommonsPublicationSubdomainSchema, CommonsPreparePublicationInputSchema, CommonsValidatePublicationInputSchema, CommonsClaimPublicationInputSchema, CommonsPublishEditorialInputSchema, CommonsGetPublicationInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryCsvArtifactOutput, DirectoryWorkflowOutputSchema, LocationDatasetProvenanceOutput, LocationMarketsOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, DiffPageOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, CheckSiteExportOutputSchema, ArchiveEntryOutputSchema, ArchiveReadOutputSchema, MapsPlaceIntelOutputSchema, TrustpilotReviewsOutputSchema, G2ReviewsOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, WaybackCaptureOutputSchema, MapWaybackSnapshotsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, VideoFrameAnalysisOutputSchema, VideoFrameAnalysisStatusOutputSchema, RedditThreadOutputSchema, RedditTrendingOutputSchema, FacebookPageIntelOutputSchema, GoogleAdsSearchOutputSchema, GoogleAdsPageIntelOutputSchema, TranscriptSignalOutput, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, GoogleAdsTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema, ReportArtifactReadInputSchema, ReportArtifactReadOutputSchema, ListServiceConnectionsInputSchema, ListServiceConnectionsOutputSchema, TestServiceConnectionInputSchema, TestServiceConnectionOutputSchema, ReadServiceConnectionInputSchema, ReadServiceConnectionOutputSchema, MetaAdCreativeMediaInputSchema, MetaAdCreativeMediaOutputSchema, ImportServiceConnectionToMemoryInputSchema, ImportServiceConnectionToMemoryOutputSchema, DescribeServiceConnectionToolInputSchema, DescribeServiceConnectionToolOutputSchema, ConnectedDataContinuationSchema, ExportConnectedServiceDataInputSchema, ConnectedDataArtifactSchema, ExportConnectedServiceDataOutputSchema, SearchConsoleTableColumnSchema, SearchConsoleTableFilterSchema, ExportSearchConsoleTableDataInputSchema, ExportSearchConsoleTableDataOutputSchema, RenewConnectedDataExportDownloadInputSchema, RenewConnectedDataExportDownloadOutputSchema, CallServiceConnectionActionInputSchema, CallServiceConnectionActionOutputSchema, SetScheduledActionConnectionsInputSchema, SetScheduledActionConnectionsOutputSchema, SlackSendMessageInputSchema, SlackSendMessageOutputSchema, GmailSendMessageInputSchema, GmailSendMessageOutputSchema, GmailSearchContactsInputSchema, GmailSearchContactsOutputSchema, GoogleCalendarCreateEventInputSchema, GoogleCalendarCreateEventOutputSchema, ZoomCreateMeetingInputSchema, ZoomCreateMeetingOutputSchema;
42571
42831
  var init_mcp_tool_schemas = __esm({
42572
42832
  "src/mcp/mcp-tool-schemas.ts"() {
42573
42833
  "use strict";
@@ -43148,7 +43408,22 @@ var init_mcp_tool_schemas = __esm({
43148
43408
  issueLabel: import_zod42.z.string().trim().min(1).max(100).default("Current edition").describe("Issue, date, or collection label in the home-page issue line."),
43149
43409
  eyebrow: import_zod42.z.string().trim().min(1).max(120).default("A guided collection").describe("Short editorial eyebrow above the home-page headline."),
43150
43410
  heroTitle: import_zod42.z.string().trim().min(1).max(180).describe("Outcome-led home-page headline for the whole reading room."),
43151
- startLabel: import_zod42.z.string().trim().min(1).max(60).default("Start reading").describe("Label for the primary start-reading button.")
43411
+ startLabel: import_zod42.z.string().trim().min(1).max(60).default("Start reading").describe("Label for the primary start-reading button."),
43412
+ ogImage: import_zod42.z.object({
43413
+ url: import_zod42.z.string().url().refine((value) => /^https?:\/\//i.test(value), "Image URL must use HTTP or HTTPS.").describe("Public HTTP(S) image URL used for the reading-room home page social preview."),
43414
+ alt: import_zod42.z.string().trim().min(1).max(500).describe("Accessible description and og:image:alt text."),
43415
+ width: import_zod42.z.number().int().positive().optional().describe("Optional intrinsic width in pixels."),
43416
+ height: import_zod42.z.number().int().positive().optional().describe("Optional intrinsic height in pixels.")
43417
+ }).strict().optional().describe("Optional collection-level Open Graph image. Individual articles may override it.")
43418
+ }).strict();
43419
+ EditorialReadingRoomImageSchema = import_zod42.z.object({
43420
+ url: import_zod42.z.string().url().refine((value) => /^https?:\/\//i.test(value), "Image URL must use HTTP or HTTPS.").describe("Public HTTP(S) image URL."),
43421
+ alt: import_zod42.z.string().trim().min(1).max(500).describe("Required accessible description of the image."),
43422
+ caption: import_zod42.z.string().trim().max(1e3).optional().describe("Optional visible caption."),
43423
+ credit: import_zod42.z.string().trim().max(500).optional().describe("Optional visible creator, publisher, or rights credit."),
43424
+ sourceUrl: import_zod42.z.string().url().refine((value) => /^https?:\/\//i.test(value), "Source URL must use HTTP or HTTPS.").optional().describe("Optional public HTTP(S) source page for provenance or rights context."),
43425
+ width: import_zod42.z.number().int().positive().optional().describe("Optional intrinsic width in pixels."),
43426
+ height: import_zod42.z.number().int().positive().optional().describe("Optional intrinsic height in pixels.")
43152
43427
  }).strict();
43153
43428
  EditorialReadingRoomArticleSchema = import_zod42.z.object({
43154
43429
  slug: import_zod42.z.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(80).describe("Unique kebab-case article identifier."),
@@ -43161,7 +43436,9 @@ var init_mcp_tool_schemas = __esm({
43161
43436
  sourceLabel: import_zod42.z.string().trim().min(1).max(500).describe("Visible provenance label naming the material this article was derived from. Do not invent a source."),
43162
43437
  revision: import_zod42.z.string().trim().min(1).max(80).optional().describe("Optional revision identifier or version label."),
43163
43438
  updatedAt: import_zod42.z.string().trim().min(1).max(80).optional().describe("Optional human-readable source update date."),
43164
- markdown: import_zod42.z.string().min(1).max(1e5).describe("Complete article body in Markdown. Use H2/H3 headings for jump links, short paragraphs, concrete examples, and tables only where they improve comparison.")
43439
+ image: EditorialReadingRoomImageSchema.optional().describe("Optional article image shown on cards and above the article body. Markdown images remain supported inside the body."),
43440
+ ogImage: EditorialReadingRoomImageSchema.optional().describe("Optional article-specific social preview image. Defaults to article.image, then site.ogImage."),
43441
+ markdown: import_zod42.z.string().min(1).max(1e5).describe("Complete article body in Markdown. Standard Markdown images are allowed with descriptive alt text. Use H2/H3 headings for jump links, short paragraphs, concrete examples, and tables only where they improve comparison.")
43165
43442
  }).strict();
43166
43443
  EditorialReadingRoomGuideInputSchema = {
43167
43444
  focus: import_zod42.z.enum(["workflow", "content_contract", "example"]).default("workflow").describe("Which part of the reusable editorial-reading-room guide to return. Start with workflow; fetch the content contract or compact example only when needed.")
@@ -43174,7 +43451,7 @@ var init_mcp_tool_schemas = __esm({
43174
43451
  CreateEditorialReadingRoomInputSchema = {
43175
43452
  site: EditorialReadingRoomSiteSchema,
43176
43453
  deck: import_zod42.z.string().trim().min(1).max(1e3).describe("Two or three sentences that explain the collection\u2019s value and scope without generic marketing language."),
43177
- articles: import_zod42.z.array(EditorialReadingRoomArticleSchema).min(1).max(40).describe("One to forty fully authored articles, with no more than 2,000,000 Markdown bytes combined. Read all in-scope source material before composing them; preserve distinctions, uncertainty, and provenance instead of flattening the corpus."),
43454
+ articles: import_zod42.z.array(EditorialReadingRoomArticleSchema).min(1).max(100).describe("One to one hundred fully authored articles, with no more than 2,000,000 Markdown bytes combined. Articles may include structured card/hero images, article-specific Open Graph images, and Markdown body images. Read all in-scope source material before composing them; preserve distinctions, uncertainty, image provenance, and rights context instead of flattening the corpus."),
43178
43455
  filename: import_zod42.z.string().trim().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/).max(120).optional().describe("Optional download filename. The server always normalizes it to a safe .html filename.")
43179
43456
  };
43180
43457
  EditorialReadingRoomArtifactSchema = import_zod42.z.object({
@@ -44001,7 +44278,13 @@ var init_mcp_tool_schemas = __esm({
44001
44278
  threadsScraped: import_zod42.z.number().int().min(0),
44002
44279
  candidatesFound: import_zod42.z.number().int().min(0),
44003
44280
  partial: import_zod42.z.boolean(),
44004
- searchQuery: import_zod42.z.string()
44281
+ searchQuery: import_zod42.z.string(),
44282
+ discoverySource: import_zod42.z.enum(["google_serp", "reddit_search_fallback", "none"]),
44283
+ resultQuality: import_zod42.z.enum(["complete", "partial", "degraded"]),
44284
+ degradedResult: import_zod42.z.boolean(),
44285
+ degradationReasons: import_zod42.z.array(import_zod42.z.string()),
44286
+ retryRecommended: import_zod42.z.boolean(),
44287
+ billingRefunded: import_zod42.z.boolean()
44005
44288
  };
44006
44289
  FacebookPageIntelOutputSchema = {
44007
44290
  advertiserName: NullableString,
@@ -45184,10 +45467,11 @@ var init_guide = __esm({
45184
45467
  2. Read every in-scope source before designing the page. Build a source inventory with purpose, authority, overlap, and provenance.
45185
45468
  3. Architect the corpus into a small editorial edition. Prefer one article per real question, decision, lesson, or reusable pattern. Merge duplicate material; preserve meaningful distinctions and uncertainty.
45186
45469
  4. Write the collection-level promise: a specific hero title and deck that explain what the reader will understand or be able to do.
45187
- 5. Author complete articles in Markdown. Use H2/H3 headings as a useful table of contents, short paragraphs, concrete examples, and tables only when they materially improve comparison.
45188
- 6. Preserve provenance in sourceLabel. Never invent a source, fact, result, quote, or certainty that the source material does not support.
45189
- 7. Call create_editorial_reading_room with the finished site metadata, deck, and ordered articles. Do not ask the renderer to discover, research, or write the content for you.
45190
- 8. Inspect the returned page at both mobile and desktop sizes. Verify navigation, jump links, article order, typography, overflow, source labels, and that the page opens without a build step.
45470
+ 5. Author complete articles in Markdown. Use H2/H3 headings as a useful table of contents, short paragraphs, concrete examples, and tables only when they materially improve comparison. Markdown images are supported inside the body.
45471
+ 6. Choose images only when they help the reader identify, understand, compare, or remember the material. Use article.image for the card/article hero, article.ogImage for a distinct social preview, and site.ogImage for the collection default. Every image needs descriptive alt text; preserve caption, credit, source URL, and rights context when known. Never invent attribution or imply usage rights.
45472
+ 7. Preserve provenance in sourceLabel. Never invent a source, fact, result, quote, or certainty that the source material does not support.
45473
+ 8. Call create_editorial_reading_room with the finished site metadata, deck, and ordered articles. Do not ask the renderer to discover, research, or write the content for you.
45474
+ 9. Inspect the returned page at both mobile and desktop sizes. Verify navigation, jump links, article order, images, alt text, typography, overflow, source labels, social metadata, and that the page opens without a build step.
45191
45475
 
45192
45476
  The renderer owns the reusable New York Times-inspired reading surface, responsive hamburger navigation, search, article jump links, progress, text-size controls, evening mode, and portable single-file HTML delivery.`;
45193
45477
  CONTENT_CONTRACT = `Editorial reading-room content contract
@@ -45200,13 +45484,16 @@ The renderer owns the reusable New York Times-inspired reading surface, responsi
45200
45484
  - site.eyebrow: collection framing, not a duplicate headline.
45201
45485
  - site.heroTitle: specific outcome or understanding promised by the collection.
45202
45486
  - site.startLabel: concise reading CTA.
45487
+ - site.ogImage: optional collection-level Open Graph image with required alt text.
45203
45488
  - deck: two or three concrete sentences covering value and scope.
45204
- - articles: 1-40 complete editorial pieces, each with a unique slug and order.
45489
+ - articles: 1-100 complete editorial pieces, each with a unique slug and order.
45205
45490
  - article.category: a repeated grouping label when multiple pieces belong together.
45206
45491
  - article.kicker: short framing line.
45207
45492
  - article.title: the question, decision, lesson, or reusable pattern.
45208
45493
  - article.summary: what the reader will understand.
45209
45494
  - article.sourceLabel: visible, truthful provenance.
45495
+ - article.image: optional card/article hero image with alt text and optional caption, credit, source URL, and dimensions.
45496
+ - article.ogImage: optional article-specific social image; otherwise article.image, then site.ogImage is used.
45210
45497
  - article.markdown: full body with useful H2/H3 headings.
45211
45498
 
45212
45499
  Quality gates:
@@ -45215,6 +45502,7 @@ Quality gates:
45215
45502
  - Do not use placeholder copy.
45216
45503
  - Keep headings descriptive enough to work as jump links.
45217
45504
  - Prefer a coherent reading sequence over source-file order.
45505
+ - Use only public HTTP(S) image URLs. Preserve provenance and rights context; an available URL is not proof of reuse rights.
45218
45506
  - If the corpus is too thin for multiple articles, create one strong article instead of padding the edition.`;
45219
45507
  EXAMPLE = `Compact example:
45220
45508
 
@@ -45228,7 +45516,11 @@ Quality gates:
45228
45516
  "issueLabel": "Pattern library",
45229
45517
  "eyebrow": "A practical field guide",
45230
45518
  "heroTitle": "Make every saved note easier to find and reuse",
45231
- "startLabel": "Begin with the pattern"
45519
+ "startLabel": "Begin with the pattern",
45520
+ "ogImage": {
45521
+ "url": "https://example.com/reading-room-social.jpg",
45522
+ "alt": "Connected notes arranged as a navigable editorial collection"
45523
+ }
45232
45524
  },
45233
45525
  "deck": "A concise guide to placing new material in the right vault, connecting it to natural neighbors, and preserving source evidence without turning memory into a generic notes bucket.",
45234
45526
  "articles": [{
@@ -45240,6 +45532,12 @@ Quality gates:
45240
45532
  "summary": "A useful note is not only stored; it is classified, connected, and made retrievable.",
45241
45533
  "sourceType": "Workflow synthesis",
45242
45534
  "sourceLabel": "Derived from the MCP Memory capture contract supplied by the user.",
45535
+ "image": {
45536
+ "url": "https://example.com/graph-operation.jpg",
45537
+ "alt": "A source note connected to related project and knowledge notes",
45538
+ "caption": "Useful captures become connected retrieval objects.",
45539
+ "sourceUrl": "https://example.com/source"
45540
+ },
45243
45541
  "markdown": "## Start with the destination\\n\\nChoose the vault whose job matches the material...\\n\\n## Connect natural neighbors\\n\\nLink only relationships supported by the notes..."
45244
45542
  }]
45245
45543
  }`;
@@ -46131,7 +46429,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
46131
46429
  }, async (input) => formatRedditThread(await executor.redditThread(input), input));
46132
46430
  server.registerTool("reddit_trending", {
46133
46431
  title: "Reddit Trending",
46134
- description: "Discover the top Reddit conversations about a topic from the last week or month: finds relevant recent threads via a Google site:reddit.com search (optionally scoped to one subreddit), scrapes them for real upvotes, comments, and the questions people asked, and ranks by engagement (upvotes + 2x comments). Scraping runs in parallel across the discovered threads; set includeComments:false for a fast, cheap discovery-only sweep (relevant thread list, no engagement stats, no per-thread billing) and then read the ones you want with reddit_thread. Not for reading one known thread URL \u2014 use reddit_thread for that.",
46432
+ description: "Discover top Reddit conversations from the last week or month. It tries Google site:reddit.com discovery, falls back to a bounded direct Reddit search when that SERP is empty or unavailable, then optionally scrapes threads for real upvotes, comments, questions, and engagement ranking. Inspect resultQuality, discoverySource, degradationReasons, retryRecommended, and billingRefunded before treating an empty result as a genuine lack of discussion. Set includeComments:false for a cheap discovery-only sweep; use reddit_thread for one known URL.",
46135
46433
  inputSchema: RedditTrendingInputSchema,
46136
46434
  outputSchema: recordOutputSchema("reddit_trending", RedditTrendingOutputSchema),
46137
46435
  annotations: liveWebToolAnnotations("Reddit Trending")
@@ -46370,7 +46668,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
46370
46668
  }, async (input) => executor.commonsPreparePublication(input));
46371
46669
  server.registerTool("commons_validate_publication", {
46372
46670
  title: "Validate Transparent Commons Publication",
46373
- description: "Validate a publication name claim or a complete source-grounded editorial edition without writing. Use operation claim before commons_claim_publication and operation publish before commons_publish_editorial. This uses the editorial reading-room contract and checks ownership plus revision conflicts.",
46671
+ description: "Validate a publication name claim or a complete source-grounded editorial edition without writing. Publish validation accepts up to 100 articles plus structured article/card images, Markdown body images, and collection/article Open Graph images under the editorial reading-room contract. Use operation claim before commons_claim_publication and operation publish before commons_publish_editorial; ownership and revision conflicts are checked.",
46374
46672
  inputSchema: CommonsValidatePublicationInputSchema,
46375
46673
  outputSchema: recordOutputSchema("commons_validate_publication", CommonsGenericOutputSchema),
46376
46674
  annotations: { title: "Validate Transparent Commons Publication", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
@@ -46384,7 +46682,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
46384
46682
  }, async (input) => executor.commonsClaimPublication(input));
46385
46683
  server.registerTool("commons_publish_editorial", {
46386
46684
  title: "Publish Transparent Commons Editorial Edition",
46387
- description: "Publish a fully authored editorial reading-room edition to the caller-owned Transparent Commons subdomain and return permanent root, archive, and edition URLs. The calling AI must research and author the source-grounded edition first; this tool validates, renders, and persists it. For an existing edition, pass its current baseRevision. Requires an idempotencyKey; this is not the neutral wiki write tool.",
46685
+ description: "Publish up to 100 fully authored editorial pieces, including optional article/card images, Markdown body images, and collection/article Open Graph images, to the caller-owned Transparent Commons subdomain. The calling AI must research and author the source-grounded edition first; image URLs need alt text and preserved provenance/rights context. The tool validates, renders, persists, and returns permanent root, archive, and edition URLs. Existing editions require current baseRevision. Requires idempotencyKey; this is not the neutral wiki write tool.",
46388
46686
  inputSchema: CommonsPublishEditorialInputSchema,
46389
46687
  outputSchema: recordOutputSchema("commons_publish_editorial", CommonsGenericOutputSchema),
46390
46688
  annotations: { title: "Publish Transparent Commons Editorial Edition", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
@@ -46521,7 +46819,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
46521
46819
  }, async (input) => formatWorkflowArtifactRead(await executor.workflowArtifactRead(input), input));
46522
46820
  server.registerTool("editorial_reading_room_guide", {
46523
46821
  title: "Editorial Reading Room Guide",
46524
- description: 'Read the reusable composition contract before creating an editorial reading room. It tells the calling AI how to inventory the supplied corpus, preserve source truth, architect a coherent edition, write useful articles, and verify the finished page. Start with focus "workflow"; fetch "content_contract" or "example" only when needed. This does not research, write, or create a page.',
46822
+ description: 'Read the reusable composition contract before creating an editorial reading room. It covers corpus inventory, source truth, coherent architecture, up to 100 articles, purposeful images, image provenance, collection/article Open Graph images, and finished-page verification. Start with focus "workflow"; fetch "content_contract" or "example" only when needed. This does not research, write, or create a page.',
46525
46823
  inputSchema: EditorialReadingRoomGuideInputSchema,
46526
46824
  outputSchema: recordOutputSchema("editorial_reading_room_guide", EditorialReadingRoomGuideOutputSchema),
46527
46825
  annotations: localPlanningToolAnnotations("Editorial Reading Room Guide")
@@ -46537,7 +46835,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
46537
46835
  }));
46538
46836
  server.registerTool("create_editorial_reading_room", {
46539
46837
  title: "Create Editorial Reading Room",
46540
- description: `Turn fully authored, source-grounded articles into one polished mobile-first editorial report with contents, search, hamburger navigation, article jump links, reading progress, text sizing, evening mode, and provenance. Do not use this tool to discover, preview, save, or version reusable website templates; use list_artifact_templates and get_artifact_template_example for that workflow. The calling AI must first read all in-scope material and use editorial_reading_room_guide when it has not already internalized the workflow; this renderer does not perform research or invent copy. ${fileBehavior("Local stdio clients save one self-contained HTML file under the MCP Scraper output directory and return localPath.", "Hosted/app clients receive an owner-scoped private HTML artifact retained for seven days with a renewable signed download URL.")}`,
46838
+ description: `Render up to 100 fully authored, source-grounded articles into one polished mobile-first editorial report with contents, search, navigation, jump links, progress, text sizing, evening mode, provenance, structured article/card images, Markdown body images, and collection/article Open Graph metadata. Public HTTP(S) image URLs require alt text; preserve caption, credit, source, and rights context when known. The static artifact carries collection OG tags and updates article tags in-browser; a publishing host must serve article-specific metadata for crawler-perfect per-article unfurls. This renderer does not research or invent copy. For reusable website templates use list_artifact_templates instead. ${fileBehavior("Local stdio clients save one self-contained HTML file under the MCP Scraper output directory and return localPath.", "Hosted/app clients receive an owner-scoped private HTML artifact retained for seven days with a renewable signed download URL.")}`,
46541
46839
  inputSchema: CreateEditorialReadingRoomInputSchema,
46542
46840
  outputSchema: recordOutputSchema("create_editorial_reading_room", CreateEditorialReadingRoomOutputSchema),
46543
46841
  annotations: {
@@ -46762,6 +47060,29 @@ var init_paa_mcp_server = __esm({
46762
47060
  });
46763
47061
 
46764
47062
  // src/mcp/http-mcp-tool-executor.ts
47063
+ function unclassifiedTransportFailure(path6, err) {
47064
+ const name = err instanceof Error ? err.name : typeof err;
47065
+ const detail = err instanceof Error ? err.message : String(err);
47066
+ console.error(JSON.stringify({
47067
+ event: "mcp_executor_transport_failed",
47068
+ path: path6,
47069
+ error_name: name,
47070
+ error_message: sanitizeVendorName(detail).slice(0, 400)
47071
+ }));
47072
+ return {
47073
+ content: [{
47074
+ type: "text",
47075
+ text: JSON.stringify({
47076
+ error: "service_unavailable",
47077
+ error_type: name,
47078
+ retryable: true,
47079
+ path: path6,
47080
+ message: publicErrorMessage("service_unavailable")
47081
+ })
47082
+ }],
47083
+ isError: true
47084
+ };
47085
+ }
46765
47086
  function youtubeVideoIdFromUrl(url) {
46766
47087
  if (!url) return null;
46767
47088
  try {
@@ -46871,7 +47192,7 @@ var init_http_mcp_tool_executor = __esm({
46871
47192
  const rawOverride = process.env.MCP_SCRAPER_HTTP_TIMEOUT_MS;
46872
47193
  const parsedOverride = rawOverride === void 0 ? NaN : Number(rawOverride);
46873
47194
  this.httpTimeoutOverrideMs = Number.isFinite(parsedOverride) && parsedOverride > 0 ? parsedOverride : null;
46874
- this.timeoutMs = this.httpTimeoutOverrideMs ?? 11e4;
47195
+ this.timeoutMs = this.httpTimeoutOverrideMs ?? DEFAULT_TOOL_CLIENT_TIMEOUT_MS;
46875
47196
  const configuredSerpIntelligenceTimeoutMs = Number(process.env.MCP_SCRAPER_SERP_INTELLIGENCE_HTTP_TIMEOUT_MS ?? this.timeoutMs);
46876
47197
  this.serpIntelligenceTimeoutMs = Number.isFinite(configuredSerpIntelligenceTimeoutMs) && configuredSerpIntelligenceTimeoutMs > 0 ? configuredSerpIntelligenceTimeoutMs : this.timeoutMs;
46877
47198
  }
@@ -46910,7 +47231,7 @@ var init_http_mcp_tool_executor = __esm({
46910
47231
  isError: true
46911
47232
  };
46912
47233
  }
46913
- return { content: [{ type: "text", text: publicErrorMessage("service_unavailable") }], isError: true };
47234
+ return unclassifiedTransportFailure(path6, err);
46914
47235
  }
46915
47236
  }
46916
47237
  async callConnectedMutation(path6, body, idempotencyKey3, timeoutMs = this.timeoutMs) {
@@ -46944,8 +47265,8 @@ var init_http_mcp_tool_executor = __esm({
46944
47265
  return { content: [{ type: "text", text: JSON.stringify(httpErrorPayload(path6, res, data)) }], isError: true };
46945
47266
  }
46946
47267
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
46947
- } catch {
46948
- return { content: [{ type: "text", text: publicErrorMessage("service_unavailable") }], isError: true };
47268
+ } catch (err) {
47269
+ return unclassifiedTransportFailure(path6, err);
46949
47270
  }
46950
47271
  }
46951
47272
  async getTextArtifact(path6, maxBytes, timeoutMs = this.timeoutMs) {
@@ -46982,7 +47303,7 @@ var init_http_mcp_tool_executor = __esm({
46982
47303
  }]
46983
47304
  };
46984
47305
  } catch (err) {
46985
- return { content: [{ type: "text", text: publicErrorMessage("service_unavailable") }], isError: true };
47306
+ return unclassifiedTransportFailure(path6, err);
46986
47307
  }
46987
47308
  }
46988
47309
  harvestPaa(input) {
@@ -50222,7 +50543,7 @@ var init_memory_tool_schemas = __esm({
50222
50543
  ExportSchema = {
50223
50544
  id: "memory-export",
50224
50545
  upstreamName: "exportTool",
50225
- description: "Export every note in a vault as a full dump for backup, migration, or bulk download \u2014 path, title, full content, kind, and last-updated per note, plus a count. Defaults to the active (or first entitled) vault. Requires export scope; the export is logged to provenance.",
50546
+ description: "Export every full note in one vault for an explicitly requested backup, migration, or corpus-wide audit \u2014 path, title, content, kind, last-updated, and count. This can be large and is not the normal way to find or edit one note: use memory-list for complete metadata inventory, memory-search for ranked recall, and memory-get for exact content. Defaults to the active or first entitled vault. Requires export scope; the export is logged to provenance.",
50226
50547
  input: {
50227
50548
  vault: import_zod47.z.string().optional().describe(
50228
50549
  "Vault to export. Optional; defaults to the session active vault, then the first vault the caller is entitled to."
@@ -50274,7 +50595,7 @@ var init_memory_tool_schemas = __esm({
50274
50595
  GetSchema = {
50275
50596
  id: "memory-get",
50276
50597
  upstreamName: "getTool",
50277
- description: "Read a single note from a vault by its exact path, or by shareId for a note shared with you and accepted. Owned notes include their stored Obsidian props so edits can preserve links and template metadata. Returns a revision number \u2014 pass it as baseRevision on a later memory-put/delete-note to detect a concurrent edit instead of silently overwriting it. Requires read scope.",
50598
+ description: "Read one complete note by exact vault+path, or by accepted shareId. After memory-search identifies a strong candidate, use this before relying on it for an answer, summary, edit, link, or durable write: search results are excerpts, not complete notes. Owned notes include stored Obsidian props so edits preserve links and template metadata. Returns the revision required as baseRevision on later edits/deletes. Requires read scope.",
50278
50599
  input: {
50279
50600
  vault: import_zod47.z.string().optional().describe(
50280
50601
  "Vault to read from. Optional; defaults to the session active vault, then the first vault the caller is entitled to. Ignored when shareId is given."
@@ -50311,7 +50632,7 @@ var init_memory_tool_schemas = __esm({
50311
50632
  ListSchema = {
50312
50633
  id: "memory-list",
50313
50634
  upstreamName: "listTool",
50314
- description: "List notes in a vault \u2014 path, title, kind, tags, last-updated \u2014 optionally filtered by kind and/or tags (matches ANY given tag). Defaults to the active or first entitled vault; also returns vaults the caller is entitled to. Requires read scope.",
50635
+ description: "Return every note in one vault as a complete metadata inventory \u2014 stable path, title, kind, tags, and last-updated \u2014 plus a sorted list of every unique folder and nested folder represented by those paths. It contains no bodies. Use memory-get for exact full content, memory-search for ranked semantic recall, or memory-export only when explicitly asked for every full note. Defaults to the active or first entitled vault; also returns entitled vaults. Requires read scope.",
50315
50636
  input: {
50316
50637
  vault: import_zod47.z.string().optional().describe(
50317
50638
  "Vault to list. Optional; defaults to the session active vault, then the first vault the caller is entitled to."
@@ -50331,6 +50652,7 @@ var init_memory_tool_schemas = __esm({
50331
50652
  updatedAt: import_zod47.z.string().describe("ISO-8601 timestamp of the note last update.")
50332
50653
  })
50333
50654
  ).optional().describe("The notes in the vault (metadata only, no content). Present when ok is true."),
50655
+ folders: import_zod47.z.array(import_zod47.z.string()).optional().describe("Sorted complete folder inventory derived from note paths, including represented nested parent folders. Present when ok is true."),
50334
50656
  vaults: import_zod47.z.array(import_zod47.z.string()).optional().describe("All vaults the caller is entitled to, for choosing a different vault to list."),
50335
50657
  error: import_zod47.z.string().optional().describe("Human-readable failure reason when ok is false.")
50336
50658
  },
@@ -50365,7 +50687,7 @@ var init_memory_tool_schemas = __esm({
50365
50687
  PutSchema = {
50366
50688
  id: "memory-put",
50367
50689
  upstreamName: "putTool",
50368
- description: "Create or deliberately edit one note at a path in a memory vault; content is persisted and indexed for search. For normal new People, Organizations, Deals, Projects, Tasks, or Communication records, use prepare-memory-write then memory-capture: People must be real people, Organizations must be real organizations, Deals need a known party, Projects need a supported kind, Tasks can be independent Inbox todos or use a verified Project when linked, and draft emails need pending approval. For row-shaped datasets you'll filter/sort by exact value, use table-create/table-insert-rows/table-query instead. Ordinary vaults are indexed and shareable \u2014 never store real secrets there; use a secure vault (create-secure-vault) instead, which is never indexed or shareable and is encrypted at rest. Requires write scope.",
50690
+ description: "Create or deliberately edit one note at a stable vault+path. On an existing note, first call memory-get, merge the requested change into its full body, and pass that complete body plus baseRevision: content is replace-all, while supplied props patch the stored props. A conflict returns the current body for reconciliation; never retry the stale full body unchanged. Use purpose-built tools instead of rewriting system-managed Agent Inbox, optimizer, or channel backing notes. For normal new People, Organizations, Deals, Projects, Tasks, or Communication records, use prepare-memory-write then memory-capture. For row-shaped datasets use table tools. Ordinary vaults are indexed and shareable; store real secrets only in a secure vault. Requires write scope.",
50369
50691
  input: {
50370
50692
  vault: import_zod47.z.string().optional().describe(
50371
50693
  "Vault to write to. Optional; defaults to the session active vault, then the first vault the caller is entitled to. On a default-provisioned account, pick the vault whose job matches the content (see the server instructions for the full 16-vault guide) rather than defaulting blindly \u2014 e.g. a lesson learned goes in Knowledge, the raw source it came from goes in Library, a broken feature goes in Issues, a named real-world initiative goes in Projects. Do not use this low-level tool to create ordinary People, Organizations, Deals, Projects, Tasks, or Communication records: first use prepare-memory-write then memory-capture so relationships and approval state are validated."
@@ -50373,9 +50695,9 @@ var init_memory_tool_schemas = __esm({
50373
50695
  path: import_zod47.z.string().optional().describe("Vault-relative note path to create or overwrite, e.g. projects/q3-plan. Writing an existing path replaces it. Required unless shareId is given."),
50374
50696
  shareId: import_zod47.z.string().optional().describe("Edit a note someone individually shared with you and you accepted (accept-share), by its shareId, instead of vault+path. Requires the share to grant edit permission, and baseRevision is mandatory (get the current revision first) since you are editing alongside the owner and possibly others."),
50375
50697
  title: import_zod47.z.string().optional().describe("Optional human-readable title; defaults are derived from the path when omitted."),
50376
- content: import_zod47.z.string().min(1).describe("The full note body to store and index for semantic search. Must be non-empty."),
50698
+ content: import_zod47.z.string().min(1).describe("The complete note body to store and index. On edit this replaces the prior body, so merge the requested change into the full memory-get content before calling; never pass only the changed fragment."),
50377
50699
  props: putTool_notePropsSchema.optional().describe("Obsidian note primitives plus vault-specific template fields. On edits, supplied fields patch the stored props instead of replacing the whole object; pass an empty array to deliberately clear a link list. Type/domain/folder also steer routing when no vault is given."),
50378
- baseRevision: import_zod47.z.number().optional().describe("Revision the edit is based on (from a prior get/put). When provided, the write only applies if the note is still at this revision; otherwise it is rejected as a conflict instead of silently overwriting a concurrent edit. Omit for last-write-wins (fine for solo notes)."),
50700
+ baseRevision: import_zod47.z.number().optional().describe("Revision the edit is based on (from memory-get/put). Always supply it when an AI edits an existing note; a mismatch rejects the write and returns current content for reconciliation. Omit only for an intentional new note or explicit last-write-wins migration."),
50379
50701
  tagDescriptions: import_zod47.z.record(import_zod47.z.string(), import_zod47.z.string()).optional().describe("One-line meaning for any tag in props.tags that is new to the account, keyed by tag. Tags resolve against the account's existing vocabulary; new tags require a one-line description.")
50380
50702
  },
50381
50703
  output: {
@@ -50409,7 +50731,7 @@ var init_memory_tool_schemas = __esm({
50409
50731
  SearchSchema = {
50410
50732
  id: "memory-search",
50411
50733
  upstreamName: "searchTool",
50412
- description: "Default Smart RAG search across accessible memory. Form 2-4 focused query variants, combine semantic matches with exact vault/tag/date/kind/type/metadata filters, expand one bounded hop of outgoing links and backlinks around strong seeds, then rerank. Defaults: retrieve/fuse 50 candidates, 8 graph seeds, 5 neighbors per seed, rerank to 30. Graph neighbors are candidates, never automatic winners or links. Before tagging or writing, also call list-memory-tags to inspect the complete vocabulary and reuse existing tags; read strong related notes before selecting links.",
50734
+ description: "Default first tool whenever the user wants to find, recall, understand, or connect Memory content without an exact vault+path. Hybrid Smart RAG combines 2-4 semantic query variants with exact vault/tag/date/kind/type/metadata matches, expands one bounded graph hop, then reranks. Results are ranked excerpts, never an exhaustive inventory or complete notes: deduplicate by vault/path and call memory-get on strong candidates before answering, summarizing, editing, linking, or writing. Use memory-list for every note, memory-suggest for title-only lookup, and memory-export only for explicit full-vault dumps. Before tagging or writing, also inspect list-memory-tags.",
50413
50735
  input: {
50414
50736
  vault: import_zod47.z.string().optional().describe("Exact logical vault handle to search. Omit to search every entitled vault."),
50415
50737
  query: import_zod47.z.string().min(1).describe("A focused semantic reformulation of the request."),
@@ -51586,6 +51908,24 @@ var init_memory_mcp_server = __esm({
51586
51908
  });
51587
51909
 
51588
51910
  // src/mcp/memory-mcp-tool-executor.ts
51911
+ function folderInventory(result) {
51912
+ if (!Array.isArray(result.notes)) return [];
51913
+ const folders = /* @__PURE__ */ new Set();
51914
+ for (const note of result.notes) {
51915
+ if (!note || typeof note !== "object" || Array.isArray(note)) continue;
51916
+ const path6 = note.path;
51917
+ if (typeof path6 !== "string") continue;
51918
+ const segments = path6.replace(/\\/g, "/").split("/").filter(Boolean);
51919
+ for (let depth = 1; depth < segments.length; depth += 1) {
51920
+ folders.add(segments.slice(0, depth).join("/"));
51921
+ }
51922
+ }
51923
+ return [...folders].sort((a, b) => a.localeCompare(b));
51924
+ }
51925
+ function enrichMemoryResult(toolName, result) {
51926
+ if (toolName !== "listTool" || result.ok !== true) return result;
51927
+ return { ...result, folders: folderInventory(result) };
51928
+ }
51589
51929
  var MemoryMcpToolExecutor;
51590
51930
  var init_memory_mcp_tool_executor = __esm({
51591
51931
  "src/mcp/memory-mcp-tool-executor.ts"() {
@@ -51612,7 +51952,10 @@ var init_memory_mcp_tool_executor = __esm({
51612
51952
  const message = data?.error ?? `memory ${toolName} failed (HTTP ${res.status})`;
51613
51953
  return { content: [{ type: "text", text: message }], isError: true };
51614
51954
  }
51615
- const result = data ?? { ok: false, error: `memory ${toolName} returned no result` };
51955
+ const result = enrichMemoryResult(
51956
+ toolName,
51957
+ data ?? { ok: false, error: `memory ${toolName} returned no result` }
51958
+ );
51616
51959
  return {
51617
51960
  content: [{ type: "text", text: JSON.stringify(result) }],
51618
51961
  structuredContent: result,
@@ -53305,13 +53648,21 @@ async function setBilledMc(id, billedMc) {
53305
53648
  args: [Math.round(billedMc), id]
53306
53649
  });
53307
53650
  }
53308
- async function markSessionClosed(id) {
53651
+ async function markSessionClosed(id, runtimeClosed = true) {
53309
53652
  const db = getDb();
53310
53653
  await db.execute({
53311
- sql: `UPDATE browser_agent_sessions SET status = 'closed', closed_at = datetime('now') WHERE id = ?`,
53312
- args: [id]
53654
+ sql: `UPDATE browser_agent_sessions SET status = ?, closed_at = datetime('now') WHERE id = ?`,
53655
+ args: [runtimeClosed ? "closed" : ORPHANED_SESSION_STATUS, id]
53313
53656
  });
53314
53657
  }
53658
+ async function listOrphanedSessionRows(limit = 50) {
53659
+ const db = getDb();
53660
+ const res = await db.execute({
53661
+ sql: `SELECT * FROM browser_agent_sessions WHERE status = ? ORDER BY closed_at LIMIT ?`,
53662
+ args: [ORPHANED_SESSION_STATUS, Math.max(1, Math.min(200, Math.round(limit)))]
53663
+ });
53664
+ return res.rows;
53665
+ }
53315
53666
  async function recordAction(input) {
53316
53667
  const db = getDb();
53317
53668
  await db.execute({
@@ -53358,7 +53709,7 @@ async function listReplayRows(sessionId) {
53358
53709
  });
53359
53710
  return res.rows;
53360
53711
  }
53361
- var import_node_crypto28, _ready2, _migrationPromise2;
53712
+ var import_node_crypto28, _ready2, _migrationPromise2, ORPHANED_SESSION_STATUS;
53362
53713
  var init_browser_agent_db = __esm({
53363
53714
  "src/api/browser-agent-db.ts"() {
53364
53715
  "use strict";
@@ -53366,6 +53717,7 @@ var init_browser_agent_db = __esm({
53366
53717
  init_db();
53367
53718
  _ready2 = false;
53368
53719
  _migrationPromise2 = null;
53720
+ ORPHANED_SESSION_STATUS = "runtime_close_pending";
53369
53721
  }
53370
53722
  });
53371
53723
 
@@ -54522,6 +54874,21 @@ async function charge(sessionId, userId, _startedAtMs) {
54522
54874
  if (res.ok) await setBilledMc(sessionId, owed);
54523
54875
  }
54524
54876
  }
54877
+ async function closeRuntimeSession(runtimeSessionId, sessionId) {
54878
+ try {
54879
+ await closeSession(runtimeSessionId);
54880
+ return true;
54881
+ } catch (err) {
54882
+ console.error(JSON.stringify({
54883
+ event: "browser_runtime_close_failed",
54884
+ session_id: sessionId,
54885
+ runtime_session_id: runtimeSessionId,
54886
+ error_name: err instanceof Error ? err.name : typeof err,
54887
+ message: sanitizeVendorName(err instanceof Error ? err.message : String(err)).slice(0, 300)
54888
+ }));
54889
+ return false;
54890
+ }
54891
+ }
54525
54892
  async function closeAndSettleSession(row) {
54526
54893
  if (row.user_id != null) {
54527
54894
  try {
@@ -54529,15 +54896,24 @@ async function closeAndSettleSession(row) {
54529
54896
  } catch {
54530
54897
  }
54531
54898
  }
54532
- try {
54533
- await closeSession(row.runtime_session_id);
54534
- } catch {
54535
- }
54536
- await markSessionClosed(row.id).catch(() => {
54899
+ const runtimeClosed = await closeRuntimeSession(row.runtime_session_id, row.id);
54900
+ await markSessionClosed(row.id, runtimeClosed).catch(() => {
54537
54901
  });
54538
54902
  await releaseConcurrencyGate(row.concurrency_lock_id).catch(() => {
54539
54903
  });
54540
54904
  }
54905
+ async function retryOrphanedBrowserSessions(limit = 50) {
54906
+ const rows = await listOrphanedSessionRows(limit);
54907
+ let recovered = 0;
54908
+ for (const row of rows) {
54909
+ if (await closeRuntimeSession(row.runtime_session_id, row.id)) {
54910
+ await markSessionClosed(row.id, true).catch(() => {
54911
+ });
54912
+ recovered++;
54913
+ }
54914
+ }
54915
+ return { recovered, stillOpen: rows.length - recovered };
54916
+ }
54541
54917
  async function completeAuthConnection(connectionId) {
54542
54918
  const connection = await getAuthConnectionRow(connectionId);
54543
54919
  if (!connection) return { ok: false, error: "not found" };
@@ -54562,7 +54938,7 @@ async function reapIdleBrowserSessions(idleSeconds = 120) {
54562
54938
  function publicSession(row) {
54563
54939
  return {
54564
54940
  session_id: row.id,
54565
- status: row.status,
54941
+ status: row.status === ORPHANED_SESSION_STATUS ? "closed" : row.status,
54566
54942
  label: row.label,
54567
54943
  watch_url: `/console/${row.id}`,
54568
54944
  created_at: row.created_at,
@@ -59174,6 +59550,24 @@ function safeFilename3(requested, slug2) {
59174
59550
  function escapedJsonForHtml(value) {
59175
59551
  return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
59176
59552
  }
59553
+ function socialMeta(input) {
59554
+ const image = input.site.ogImage;
59555
+ const tags = [
59556
+ '<meta property="og:type" content="website">',
59557
+ `<meta property="og:title" content="${escapeHtml6(input.site.title)}">`,
59558
+ `<meta property="og:description" content="${escapeHtml6(input.deck || input.site.heroTitle)}">`,
59559
+ '<meta name="twitter:card" content="summary_large_image">',
59560
+ `<meta name="twitter:title" content="${escapeHtml6(input.site.title)}">`,
59561
+ `<meta name="twitter:description" content="${escapeHtml6(input.deck || input.site.heroTitle)}">`,
59562
+ `<meta property="og:image" content="${image ? escapeHtml6(image.url) : ""}">`,
59563
+ `<meta property="og:image:alt" content="${image ? escapeHtml6(image.alt) : ""}">`,
59564
+ `<meta property="og:image:width" content="${image?.width ?? ""}">`,
59565
+ `<meta property="og:image:height" content="${image?.height ?? ""}">`,
59566
+ `<meta name="twitter:image" content="${image ? escapeHtml6(image.url) : ""}">`,
59567
+ `<meta name="twitter:image:alt" content="${image ? escapeHtml6(image.alt) : ""}">`
59568
+ ];
59569
+ return tags.join("\n ");
59570
+ }
59177
59571
  function renderEditorialReadingRoom(input, now = /* @__PURE__ */ new Date()) {
59178
59572
  const sourceBytes = input.articles.reduce((total, article) => total + Buffer.byteLength(article.markdown), 0);
59179
59573
  if (sourceBytes > 2e6) throw new Error("Editorial reading-room Markdown must be 2,000,000 bytes or fewer in total.");
@@ -59206,7 +59600,7 @@ function renderEditorialReadingRoom(input, now = /* @__PURE__ */ new Date()) {
59206
59600
  const generatedAt = now.toISOString();
59207
59601
  const data = { ...input, generatedAt, articles };
59208
59602
  const template = readAsset("index.html");
59209
- const html = template.replace("<title>Editorial Reading Room</title>", `<title>${escapeHtml6(input.site.title)}</title>`).replace('content="A mobile-first editorial reading room."', `content="${escapeHtml6(input.deck || input.site.heroTitle)}"`).replace("/*__READING_ROOM_STYLES__*/", readAsset("styles.css")).replace("/*__READING_ROOM_DATA__*/", escapedJsonForHtml(data)).replace("/*__READING_ROOM_SCRIPT__*/", readAsset("app.js"));
59603
+ const html = template.replace("<title>Editorial Reading Room</title>", `<title>${escapeHtml6(input.site.title)}</title>`).replace('content="A mobile-first editorial reading room."', `content="${escapeHtml6(input.deck || input.site.heroTitle)}"`).replace("<!--__READING_ROOM_SOCIAL_META__-->", socialMeta(input)).replace("/*__READING_ROOM_STYLES__*/", readAsset("styles.css")).replace("/*__READING_ROOM_DATA__*/", escapedJsonForHtml(data)).replace("/*__READING_ROOM_SCRIPT__*/", readAsset("app.js"));
59210
59604
  const bytes = Buffer.byteLength(html);
59211
59605
  return {
59212
59606
  html,
@@ -59447,6 +59841,38 @@ var init_editorial_reading_room_routes = __esm({
59447
59841
  }
59448
59842
  });
59449
59843
 
59844
+ // src/api/schema-presence.ts
59845
+ async function loadSchemaObjects() {
59846
+ const res = await getDb().execute(
59847
+ "SELECT name FROM sqlite_master WHERE type IN ('table', 'index')"
59848
+ );
59849
+ return new Set(res.rows.map((row) => String(row.name)));
59850
+ }
59851
+ function invalidateSchemaPresence() {
59852
+ cache = null;
59853
+ }
59854
+ async function schemaObjectsPresent(names) {
59855
+ if (names.length === 0) return true;
59856
+ try {
59857
+ if (!cache) cache = loadSchemaObjects();
59858
+ const present = await cache;
59859
+ if (names.every((name) => present.has(name))) return true;
59860
+ invalidateSchemaPresence();
59861
+ return false;
59862
+ } catch {
59863
+ invalidateSchemaPresence();
59864
+ return false;
59865
+ }
59866
+ }
59867
+ var cache;
59868
+ var init_schema_presence = __esm({
59869
+ "src/api/schema-presence.ts"() {
59870
+ "use strict";
59871
+ init_db();
59872
+ cache = null;
59873
+ }
59874
+ });
59875
+
59450
59876
  // src/api/commons-linksets.ts
59451
59877
  function normalizeCommonsClaims(input) {
59452
59878
  if (input === void 0) return void 0;
@@ -59632,7 +60058,11 @@ var init_commons_linksets = __esm({
59632
60058
 
59633
60059
  // src/api/commons-repository.ts
59634
60060
  async function ensureCommonsSchema() {
59635
- if (schemaReady2) return;
60061
+ if (schemaReady) return;
60062
+ if (await schemaObjectsPresent(COMMONS_SCHEMA_OBJECTS)) {
60063
+ schemaReady = true;
60064
+ return;
60065
+ }
59636
60066
  const db = getDb();
59637
60067
  await db.batch([
59638
60068
  {
@@ -59853,7 +60283,7 @@ async function ensureCommonsSchema() {
59853
60283
  args: [COMMONS_SCHEMA_VERSION]
59854
60284
  }
59855
60285
  ], "write");
59856
- schemaReady2 = true;
60286
+ schemaReady = true;
59857
60287
  }
59858
60288
  async function searchCommonsEntities(filters = {}, userId) {
59859
60289
  await ensureCommonsSchema();
@@ -61568,12 +61998,13 @@ function jsonLikeValue(value) {
61568
61998
  function escapeLike(value) {
61569
61999
  return value.replace(/[%_]/g, "");
61570
62000
  }
61571
- var import_node_crypto35, COMMONS_SCHEMA_VERSION, DEFAULT_COMMONS_BASE_URL, DEFAULT_ENTITY_TYPE, COMMONS_ENTITY_PROFILES, schemaReady2, CommonsRepositoryError;
62001
+ var import_node_crypto35, COMMONS_SCHEMA_VERSION, DEFAULT_COMMONS_BASE_URL, DEFAULT_ENTITY_TYPE, COMMONS_ENTITY_PROFILES, schemaReady, COMMONS_SCHEMA_OBJECTS, CommonsRepositoryError;
61572
62002
  var init_commons_repository = __esm({
61573
62003
  "src/api/commons-repository.ts"() {
61574
62004
  "use strict";
61575
62005
  import_node_crypto35 = require("crypto");
61576
62006
  init_db();
62007
+ init_schema_presence();
61577
62008
  init_rates();
61578
62009
  init_commons_linksets();
61579
62010
  COMMONS_SCHEMA_VERSION = "2026-08-05.1";
@@ -61724,7 +62155,32 @@ var init_commons_repository = __esm({
61724
62155
  ]
61725
62156
  }
61726
62157
  };
61727
- schemaReady2 = false;
62158
+ schemaReady = false;
62159
+ COMMONS_SCHEMA_OBJECTS = [
62160
+ "schema_migrations",
62161
+ "commons_entities",
62162
+ "commons_claims",
62163
+ "commons_proposals",
62164
+ "commons_ledger",
62165
+ "commons_saved_filters",
62166
+ "commons_index_documents",
62167
+ "commons_publications",
62168
+ "commons_publication_editions",
62169
+ "commons_entities_status_updated",
62170
+ "commons_entities_type_updated",
62171
+ "commons_entities_source_domain",
62172
+ "commons_claims_subject_predicate",
62173
+ "commons_claims_object_entity",
62174
+ "commons_proposals_user_created",
62175
+ "commons_proposals_entity_created",
62176
+ "commons_ledger_entity_created",
62177
+ "commons_ledger_user_created",
62178
+ "commons_saved_filters_user_updated",
62179
+ "commons_index_documents_status_updated",
62180
+ "commons_index_documents_entity_type",
62181
+ "commons_publications_updated",
62182
+ "commons_publication_editions_publication_published"
62183
+ ];
61728
62184
  CommonsRepositoryError = class extends Error {
61729
62185
  constructor(code, message, httpStatus = 400) {
61730
62186
  super(message);
@@ -62681,7 +63137,10 @@ var init_analytics_attribution = __esm({
62681
63137
  // src/api/analytics-repository.ts
62682
63138
  var analytics_repository_exports = {};
62683
63139
  __export(analytics_repository_exports, {
63140
+ ANALYTICS_CONTENT_SORTS: () => ANALYTICS_CONTENT_SORTS,
62684
63141
  AnalyticsRepositoryError: () => AnalyticsRepositoryError,
63142
+ ENGAGED_SESSION_MS: () => ENGAGED_SESSION_MS,
63143
+ MAX_ENGAGED_MS: () => MAX_ENGAGED_MS,
62685
63144
  analyticsAcquisition: () => analyticsAcquisition,
62686
63145
  analyticsBusinessMetrics: () => analyticsBusinessMetrics,
62687
63146
  analyticsChannelBreakdown: () => analyticsChannelBreakdown,
@@ -62725,6 +63184,7 @@ __export(analytics_repository_exports, {
62725
63184
  migrateAnalytics: () => migrateAnalytics,
62726
63185
  normalizeAnalyticsPath: () => normalizeAnalyticsPath,
62727
63186
  normalizeAnalyticsUrl: () => normalizeAnalyticsUrl,
63187
+ normalizeContentOptions: () => normalizeContentOptions,
62728
63188
  normalizeObservedHostname: () => normalizeObservedHostname,
62729
63189
  queueAnalyticsActivation: () => queueAnalyticsActivation,
62730
63190
  queueAnalyticsFormDelivery: () => queueAnalyticsFormDelivery,
@@ -63075,6 +63535,12 @@ async function migrateAnalytics() {
63075
63535
  await client2.query(
63076
63536
  `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS utm_content text`
63077
63537
  );
63538
+ await client2.query(
63539
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS engaged_ms integer`
63540
+ );
63541
+ await client2.query(
63542
+ `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS scroll_depth smallint`
63543
+ );
63078
63544
  await client2.query(
63079
63545
  `ALTER TABLE analytics_conversions ADD COLUMN IF NOT EXISTS person_id uuid REFERENCES analytics_people(id) ON DELETE SET NULL`
63080
63546
  );
@@ -63600,6 +64066,12 @@ async function setAnalyticsPixelDomainState(input) {
63600
64066
  404
63601
64067
  );
63602
64068
  }
64069
+ function boundedEngagedMs(value) {
64070
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.min(Math.trunc(value), MAX_ENGAGED_MS) : null;
64071
+ }
64072
+ function boundedScrollDepth(value) {
64073
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.min(Math.trunc(value), 100) : null;
64074
+ }
63603
64075
  function normalizeAnalyticsPath(value) {
63604
64076
  if (!value) return "/";
63605
64077
  try {
@@ -63787,10 +64259,11 @@ async function ingestAnalyticsEvents(input) {
63787
64259
  id, event_id, site_id, pixel_id, hostname, visitor_id, session_id, event_name,
63788
64260
  occurred_at, path, canonical_url, title, referrer, source, medium, campaign,
63789
64261
  device_class, properties, country_code, region_code, click_ids, person_id,
63790
- channel_family, platform, campaign_id, ad_set_id, ad_id, creative_id, placement, utm_term, utm_content
64262
+ channel_family, platform, campaign_id, ad_set_id, ad_id, creative_id, placement, utm_term, utm_content,
64263
+ engaged_ms, scroll_depth
63791
64264
  ) VALUES (
63792
64265
  $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20, $21::jsonb, $22,
63793
- $23, $24, $25, $26, $27, $28, $29, $30, $31
64266
+ $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33
63794
64267
  ) ON CONFLICT(site_id, event_id) DO NOTHING`,
63795
64268
  [
63796
64269
  (0, import_node_crypto37.randomUUID)(),
@@ -63823,7 +64296,9 @@ async function ingestAnalyticsEvents(input) {
63823
64296
  attribution.creativeId,
63824
64297
  attribution.placement,
63825
64298
  attribution.term,
63826
- attribution.content
64299
+ attribution.content,
64300
+ boundedEngagedMs(event2.engagedMs),
64301
+ boundedScrollDepth(event2.scrollDepth)
63827
64302
  ]
63828
64303
  );
63829
64304
  if (inserted.rowCount) accepted += 1;
@@ -64334,10 +64809,21 @@ async function analyticsChannelBreakdown(siteId, userId, filters) {
64334
64809
  dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
64335
64810
  };
64336
64811
  }
64337
- async function analyticsContent(siteId, userId, filters, page) {
64812
+ function normalizeContentOptions(options) {
64813
+ const sort = ANALYTICS_CONTENT_SORTS.includes(options?.sort) ? options.sort : "pageviews";
64814
+ return {
64815
+ sort,
64816
+ direction: options?.direction === "asc" ? "asc" : "desc",
64817
+ search: options?.search?.trim().slice(0, 300) || null
64818
+ };
64819
+ }
64820
+ async function analyticsContent(siteId, userId, filters, page, options) {
64338
64821
  const db = getAnalyticsPool();
64339
64822
  await accessFor(db, siteId, userId);
64340
64823
  const query = eventFilterSql(siteId, filters);
64824
+ const table = normalizeContentOptions(options);
64825
+ const searchParam = `$${query.values.length + 1}`;
64826
+ const order = table.sort === "path" ? `metrics.path ${table.direction === "asc" ? "ASC" : "DESC"}` : `${table.sort} ${table.direction === "asc" ? "ASC" : "DESC"} NULLS LAST, metrics.path ASC`;
64341
64827
  const result = await db.query(
64342
64828
  `WITH filtered AS (
64343
64829
  SELECT e.* FROM analytics_events e WHERE ${query.where}
@@ -64345,7 +64831,7 @@ async function analyticsContent(siteId, userId, filters, page) {
64345
64831
  SELECT path, count(DISTINCT visitor_id)::int AS visitors,
64346
64832
  count(DISTINCT session_id)::int AS sessions,
64347
64833
  count(*) FILTER (WHERE event_name = 'page_view')::int AS pageviews,
64348
- count(*) FILTER (WHERE event_name <> 'page_view')::int AS engagement_events,
64834
+ count(*) FILTER (WHERE event_name NOT IN ('page_view', 'page_engagement'))::int AS engagement_events,
64349
64835
  count(*)::int AS events
64350
64836
  FROM filtered GROUP BY path
64351
64837
  ), session_bounds AS (
@@ -64353,35 +64839,98 @@ async function analyticsContent(siteId, userId, filters, page) {
64353
64839
  (array_agg(path ORDER BY occurred_at ASC, id ASC))[1] AS entrance_path,
64354
64840
  (array_agg(path ORDER BY occurred_at DESC, id DESC))[1] AS exit_path
64355
64841
  FROM filtered WHERE session_id IS NOT NULL GROUP BY session_id
64842
+ ), session_pageviews AS (
64843
+ SELECT session_id, count(*) FILTER (WHERE event_name = 'page_view')::int AS pageviews
64844
+ FROM filtered WHERE session_id IS NOT NULL GROUP BY session_id
64845
+ ), page_dwell AS (
64846
+ SELECT session_id, path,
64847
+ sum(engaged_ms)::bigint AS engaged_ms,
64848
+ max(scroll_depth)::int AS scroll_depth
64849
+ FROM filtered
64850
+ WHERE event_name = 'page_engagement' AND session_id IS NOT NULL
64851
+ GROUP BY session_id, path
64852
+ ), dwell_metrics AS (
64853
+ SELECT path,
64854
+ avg(engaged_ms) FILTER (WHERE engaged_ms IS NOT NULL) AS avg_engaged_ms,
64855
+ avg(scroll_depth) FILTER (WHERE scroll_depth IS NOT NULL) AS avg_scroll,
64856
+ count(*) FILTER (WHERE engaged_ms IS NOT NULL)::int AS measured_pageviews
64857
+ FROM page_dwell GROUP BY path
64356
64858
  ), entrances AS (
64357
64859
  SELECT entrance_path AS path, count(*)::int AS entrances FROM session_bounds GROUP BY entrance_path
64358
64860
  ), exits AS (
64359
64861
  SELECT exit_path AS path, count(*)::int AS exits FROM session_bounds GROUP BY exit_path
64862
+ ), bounces AS (
64863
+ SELECT b.entrance_path AS path, count(*)::int AS bounces
64864
+ FROM session_bounds b JOIN session_pageviews p USING(session_id)
64865
+ WHERE p.pageviews <= 1
64866
+ GROUP BY b.entrance_path
64867
+ ), converted_sessions AS (
64868
+ SELECT DISTINCT c.session_id
64869
+ FROM analytics_conversions c
64870
+ WHERE c.site_id = $1 AND c.occurred_at >= $2::timestamptz AND c.occurred_at < $3::timestamptz
64871
+ AND c.session_id IS NOT NULL
64872
+ ), path_sessions AS (
64873
+ SELECT DISTINCT path, session_id FROM filtered WHERE session_id IS NOT NULL
64874
+ ), engaged AS (
64875
+ SELECT ps.path,
64876
+ count(*) FILTER (
64877
+ WHERE sp.pageviews >= 2
64878
+ OR COALESCE(pd.engaged_ms, 0) >= ${ENGAGED_SESSION_MS}
64879
+ OR cs.session_id IS NOT NULL
64880
+ )::int AS engaged_sessions
64881
+ FROM path_sessions ps
64882
+ JOIN session_pageviews sp USING(session_id)
64883
+ LEFT JOIN page_dwell pd ON pd.session_id = ps.session_id AND pd.path = ps.path
64884
+ LEFT JOIN converted_sessions cs ON cs.session_id = ps.session_id
64885
+ GROUP BY ps.path
64360
64886
  ), assisted AS (
64361
64887
  SELECT f.path, count(DISTINCT c.id)::int AS conversions
64362
64888
  FROM analytics_conversions c JOIN filtered f ON f.session_id = c.session_id
64363
64889
  WHERE c.site_id = $1 AND c.occurred_at >= $2::timestamptz AND c.occurred_at < $3::timestamptz
64364
64890
  GROUP BY f.path
64365
64891
  )
64366
- SELECT metrics.*, COALESCE(entrances.entrances, 0)::int AS entrances,
64892
+ SELECT metrics.*,
64893
+ COALESCE(entrances.entrances, 0)::int AS entrances,
64367
64894
  COALESCE(exits.exits, 0)::int AS exits,
64368
- COALESCE(assisted.conversions, 0)::int AS conversions
64895
+ COALESCE(assisted.conversions, 0)::int AS conversions,
64896
+ COALESCE(dwell.measured_pageviews, 0)::int AS measured_pageviews,
64897
+ CASE WHEN dwell.avg_engaged_ms IS NULL THEN NULL
64898
+ ELSE round((dwell.avg_engaged_ms / 1000.0)::numeric, 1) END AS avg_seconds_on_page,
64899
+ CASE WHEN dwell.avg_scroll IS NULL THEN NULL
64900
+ ELSE round(dwell.avg_scroll::numeric, 1) END AS avg_scroll_depth,
64901
+ CASE WHEN COALESCE(entrances.entrances, 0) = 0 THEN NULL
64902
+ ELSE round(COALESCE(bounces.bounces, 0)::numeric * 100 / entrances.entrances, 1) END AS bounce_rate,
64903
+ CASE WHEN metrics.pageviews = 0 THEN NULL
64904
+ ELSE round(COALESCE(exits.exits, 0)::numeric * 100 / metrics.pageviews, 1) END AS exit_rate,
64905
+ CASE WHEN metrics.sessions = 0 THEN NULL
64906
+ ELSE round(COALESCE(engaged.engaged_sessions, 0)::numeric * 100 / metrics.sessions, 1) END AS engagement_rate,
64907
+ CASE WHEN metrics.sessions = 0 THEN NULL
64908
+ ELSE round(metrics.pageviews::numeric / metrics.sessions, 2) END AS pageviews_per_session
64369
64909
  FROM page_metrics metrics
64370
64910
  LEFT JOIN entrances USING(path)
64371
64911
  LEFT JOIN exits USING(path)
64372
64912
  LEFT JOIN assisted USING(path)
64373
- ORDER BY metrics.pageviews DESC, metrics.events DESC, metrics.path ASC
64913
+ LEFT JOIN bounces USING(path)
64914
+ LEFT JOIN engaged USING(path)
64915
+ LEFT JOIN dwell_metrics dwell USING(path)
64916
+ WHERE ${searchParam}::text IS NULL OR metrics.path ILIKE '%' || ${searchParam} || '%'
64917
+ ORDER BY ${order}
64374
64918
  LIMIT 1000`,
64375
- query.values
64919
+ [...query.values, table.search]
64376
64920
  );
64377
64921
  const paged = paginateRows(
64378
64922
  result.rows,
64379
64923
  page,
64380
- pageFingerprint("content", siteId, filters)
64924
+ pageFingerprint(
64925
+ `content:${table.sort}:${table.direction}:${table.search ?? ""}`,
64926
+ siteId,
64927
+ filters
64928
+ )
64381
64929
  );
64382
64930
  return {
64383
64931
  content: paged.items,
64384
64932
  pageInfo: paged.pageInfo,
64933
+ table,
64385
64934
  dataFreshThrough: (/* @__PURE__ */ new Date()).toISOString()
64386
64935
  };
64387
64936
  }
@@ -65211,7 +65760,7 @@ async function refreshAnalyticsDailyRollups(input) {
65211
65760
  try {
65212
65761
  await client2.query("BEGIN");
65213
65762
  await client2.query(
65214
- `DELETE FROM analytics_rollups_daily WHERE bucket >= $1::date AND bucket < $2::date`,
65763
+ `DELETE FROM analytics_rollups_daily WHERE bucket >= $1::date AND bucket <= $2::date`,
65215
65764
  [input.start, input.end]
65216
65765
  );
65217
65766
  const inserted = await client2.query(
@@ -65228,7 +65777,14 @@ async function refreshAnalyticsDailyRollups(input) {
65228
65777
  now()
65229
65778
  FROM analytics_events
65230
65779
  WHERE occurred_at >= $1::timestamptz AND occurred_at < $2::timestamptz
65231
- GROUP BY 1, 2, 3, 4, 5, 6, 7`,
65780
+ GROUP BY 1, 2, 3, 4, 5, 6, 7
65781
+ ON CONFLICT (site_id, pixel_id, hostname, bucket, source, medium, campaign)
65782
+ DO UPDATE SET
65783
+ pageviews = EXCLUDED.pageviews,
65784
+ events = EXCLUDED.events,
65785
+ visitors = EXCLUDED.visitors,
65786
+ sessions = EXCLUDED.sessions,
65787
+ refreshed_at = EXCLUDED.refreshed_at`,
65232
65788
  [input.start, input.end]
65233
65789
  );
65234
65790
  await client2.query("COMMIT");
@@ -65240,9 +65796,17 @@ async function refreshAnalyticsDailyRollups(input) {
65240
65796
  return { runId, rows };
65241
65797
  } catch (error) {
65242
65798
  await client2.query("ROLLBACK");
65799
+ const pgCode = error?.code;
65800
+ const detail = error instanceof Error ? error.message : String(error);
65801
+ console.error(JSON.stringify({
65802
+ event: "analytics_rollup_failed",
65803
+ runId,
65804
+ pgCode: pgCode ?? null,
65805
+ message: detail.slice(0, 400)
65806
+ }));
65243
65807
  await db.query(
65244
- `UPDATE analytics_rollup_runs SET status = 'failed', completed_at = now(), error_code = 'rollup_failed' WHERE id = $1`,
65245
- [runId]
65808
+ `UPDATE analytics_rollup_runs SET status = 'failed', completed_at = now(), error_code = $2 WHERE id = $1`,
65809
+ [runId, (pgCode ? `pg_${pgCode}` : "rollup_failed").slice(0, 60)]
65246
65810
  );
65247
65811
  throw error;
65248
65812
  } finally {
@@ -65295,6 +65859,24 @@ function rowsToCsv2(rows) {
65295
65859
  )
65296
65860
  ].join("\n");
65297
65861
  }
65862
+ function markdownHeading(column) {
65863
+ return column.split("_").map((word, index) => index ? word : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
65864
+ }
65865
+ function markdownCell(value) {
65866
+ if (value === null || value === void 0) return "\u2014";
65867
+ return String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
65868
+ }
65869
+ function rowsToMarkdown(rows) {
65870
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
65871
+ if (!columns.length) return ["No rows matched this window and filter set."];
65872
+ return [
65873
+ `| ${columns.map(markdownHeading).join(" | ")} |`,
65874
+ `| ${columns.map(() => "---").join(" | ")} |`,
65875
+ ...rows.map(
65876
+ (row) => `| ${columns.map((column) => markdownCell(row[column])).join(" | ")} |`
65877
+ )
65878
+ ];
65879
+ }
65298
65880
  async function createAnalyticsExport(input) {
65299
65881
  const site = (await listAnalyticsSites(input.userId)).find(
65300
65882
  (candidate) => candidate.id === input.siteId
@@ -65362,6 +65944,7 @@ async function createAnalyticsExport(input) {
65362
65944
  `- Verified conversions: ${overview.conversions}`,
65363
65945
  `- Verified revenue: ${(overview.revenueMinor / 100).toFixed(2)}`,
65364
65946
  "",
65947
+ ...input.report === "overview" ? [] : [`## ${markdownHeading(input.report)} rows`, "", ...rowsToMarkdown(rows), ""],
65365
65948
  `Generated ${(/* @__PURE__ */ new Date()).toISOString()} from the governed ${input.report} report contract.`
65366
65949
  ].join("\n");
65367
65950
  }
@@ -65398,7 +65981,7 @@ async function createAnalyticsExport(input) {
65398
65981
  }
65399
65982
  return describeArtifact(replay.rows[0]);
65400
65983
  }
65401
- var import_node_crypto37, import_pg, AnalyticsRepositoryError, pool2, blockedPropertyName, inferredFamilySql, clickIdKeys;
65984
+ var import_node_crypto37, import_pg, AnalyticsRepositoryError, pool2, MAX_ENGAGED_MS, ENGAGED_SESSION_MS, blockedPropertyName, inferredFamilySql, ANALYTICS_CONTENT_SORTS, clickIdKeys;
65402
65985
  var init_analytics_repository = __esm({
65403
65986
  "src/api/analytics-repository.ts"() {
65404
65987
  "use strict";
@@ -65417,6 +66000,8 @@ var init_analytics_repository = __esm({
65417
66000
  status;
65418
66001
  };
65419
66002
  pool2 = null;
66003
+ MAX_ENGAGED_MS = 30 * 60 * 1e3;
66004
+ ENGAGED_SESSION_MS = 1e4;
65420
66005
  blockedPropertyName = /(email|phone|password|token|secret|authorization|cookie|name|address|form|body)/i;
65421
66006
  inferredFamilySql = `COALESCE(e.channel_family, CASE
65422
66007
  WHEN lower(COALESCE(e.source,'') || ' ' || COALESCE(e.referrer,'')) ~ '(perplexity|chatgpt|openai|claude|anthropic|grok|xai|ai[_ -]?overview|google[_ -]?ai|gemini)' THEN 'llm'
@@ -65425,6 +66010,22 @@ var init_analytics_repository = __esm({
65425
66010
  WHEN COALESCE(e.source,'') = '' THEN 'direct'
65426
66011
  WHEN lower(COALESCE(e.medium,'')) ~ 'email|newsletter' THEN 'email'
65427
66012
  ELSE 'other' END)`;
66013
+ ANALYTICS_CONTENT_SORTS = [
66014
+ "path",
66015
+ "visitors",
66016
+ "sessions",
66017
+ "pageviews",
66018
+ "entrances",
66019
+ "exits",
66020
+ "engagement_events",
66021
+ "conversions",
66022
+ "avg_seconds_on_page",
66023
+ "avg_scroll_depth",
66024
+ "bounce_rate",
66025
+ "exit_rate",
66026
+ "engagement_rate",
66027
+ "pageviews_per_session"
66028
+ ];
65428
66029
  clickIdKeys = /* @__PURE__ */ new Set([
65429
66030
  "fbclid",
65430
66031
  "gclid",
@@ -65744,7 +66345,10 @@ function dashboardOriginAllowed(origin) {
65744
66345
  return (process.env.ALLOWED_ORIGINS ?? process.env.APP_ORIGIN ?? "").split(",").map((value) => value.trim()).includes(origin);
65745
66346
  }
65746
66347
  function analyticsPixelSdk() {
65747
- return `(()=>{const s=document.currentScript;if(!s)return;const pixel=s.dataset.pixel;if(!pixel)return;const endpoint=new URL('/analytics/v1/events',s.src).toString(),ttl=18e5;const key='mcp_analytics_visitor',sessionKey='mcp_analytics_session',touchKey='mcp_analytics_touch';const id=()=>crypto.randomUUID?crypto.randomUUID():'e_'+Date.now().toString(36)+Math.random().toString(36).slice(2);let consent=s.dataset.consent==='granted'&&!navigator.globalPrivacyControl,visitor=null,session=null;const read=k=>{try{return localStorage.getItem(k)}catch{return null}},write=(k,v)=>{try{localStorage.setItem(k,v)}catch{}};const ensure=()=>{if(!consent)return;visitor=read(key)||id();const now=Date.now();let saved={};try{saved=JSON.parse(read(sessionKey)||'{}')}catch{}session=saved.id&&now-Number(saved.seen||0)<ttl?saved.id:id();write(key,visitor);write(sessionKey,JSON.stringify({id:session,seen:now}))};const attribution=()=>{const q=new URLSearchParams(location.search),clickIds={},properties={},map={utm_term:'utm_term',utm_content:'utm_content',utm_id:'campaign_id',campaign_id:'campaign_id',adset_id:'ad_set_id',ad_set_id:'ad_set_id',adgroup_id:'ad_group_id',ad_group_id:'ad_group_id',ad_id:'ad_id',creative_id:'creative_id',placement:'placement'};for(const k of ['fbclid','gclid','gbraid','wbraid','ttclid','rdt_cid','msclkid']){const v=q.get(k);if(v)clickIds[k]=v}for(const [from,to] of Object.entries(map)){const v=q.get(from);if(v)properties[to]=v}const current={source:q.get('utm_source')||undefined,medium:q.get('utm_medium')||undefined,campaign:q.get('utm_campaign')||undefined,clickIds,properties};if(current.source||Object.keys(clickIds).length)write(touchKey,JSON.stringify(current));else{try{return JSON.parse(read(touchKey)||'null')||current}catch{}}return current};const send=(name,properties={})=>{if(!consent||navigator.globalPrivacyControl)return;ensure();const a=attribution();const body=JSON.stringify({pixelId:pixel,events:[{eventId:id(),eventName:name,occurredAt:new Date().toISOString(),visitorId:visitor,sessionId:session,path:location.pathname,canonicalUrl:location.origin+location.pathname,title:document.title,referrer:document.referrer||undefined,...a,deviceClass:innerWidth<640?'mobile':innerWidth<1024?'tablet':'desktop',properties:{...a.properties,...properties}}]});if(navigator.sendBeacon&&navigator.sendBeacon(endpoint,new Blob([body],{type:'application/json'})))return;fetch(endpoint,{method:'POST',headers:{'content-type':'application/json'},body,keepalive:true,credentials:'omit'}).catch(()=>{})};const api={page:()=>send('page_view'),track:send,consent:state=>{consent=(state==='granted'||state===true)&&!navigator.globalPrivacyControl;if(consent){ensure();api.page()}},debug:()=>({pixel,endpoint,consent,gpc:Boolean(navigator.globalPrivacyControl),visitorId:visitor,sessionId:session,hostname:location.hostname,path:location.pathname,...attribution()})};window.mcpAnalytics=api;ensure();if(consent)api.page();let current=location.href;const changed=()=>{if(location.href!==current){current=location.href;api.page()}};for(const method of ['pushState','replaceState']){const original=history[method];history[method]=function(...args){const result=original.apply(this,args);queueMicrotask(changed);return result}}addEventListener('popstate',changed)})();`;
66348
+ return `(()=>{const s=document.currentScript;if(!s)return;const pixel=s.dataset.pixel;if(!pixel)return;const endpoint=new URL('/analytics/v1/events',s.src).toString(),ttl=18e5;const key='mcp_analytics_visitor',sessionKey='mcp_analytics_session',touchKey='mcp_analytics_touch';const id=()=>crypto.randomUUID?crypto.randomUUID():'e_'+Date.now().toString(36)+Math.random().toString(36).slice(2);let consent=s.dataset.consent==='granted'&&!navigator.globalPrivacyControl,visitor=null,session=null;const read=k=>{try{return localStorage.getItem(k)}catch{return null}},write=(k,v)=>{try{localStorage.setItem(k,v)}catch{}};const ensure=()=>{if(!consent)return;visitor=read(key)||id();const now=Date.now();let saved={};try{saved=JSON.parse(read(sessionKey)||'{}')}catch{}session=saved.id&&now-Number(saved.seen||0)<ttl?saved.id:id();write(key,visitor);write(sessionKey,JSON.stringify({id:session,seen:now}))};const attribution=()=>{const q=new URLSearchParams(location.search),clickIds={},properties={},map={utm_term:'utm_term',utm_content:'utm_content',utm_id:'campaign_id',campaign_id:'campaign_id',adset_id:'ad_set_id',ad_set_id:'ad_set_id',adgroup_id:'ad_group_id',ad_group_id:'ad_group_id',ad_id:'ad_id',creative_id:'creative_id',placement:'placement'};for(const k of ['fbclid','gclid','gbraid','wbraid','ttclid','rdt_cid','msclkid']){const v=q.get(k);if(v)clickIds[k]=v}for(const [from,to] of Object.entries(map)){const v=q.get(from);if(v)properties[to]=v}const current={source:q.get('utm_source')||undefined,medium:q.get('utm_medium')||undefined,campaign:q.get('utm_campaign')||undefined,clickIds,properties};if(current.source||Object.keys(clickIds).length)write(touchKey,JSON.stringify(current));else{try{return JSON.parse(read(touchKey)||'null')||current}catch{}}return current};const send=(name,properties={},over={})=>{if(!consent||navigator.globalPrivacyControl)return;ensure();const a=attribution();const body=JSON.stringify({pixelId:pixel,events:[{eventId:id(),eventName:name,occurredAt:new Date().toISOString(),visitorId:visitor,sessionId:session,path:location.pathname,canonicalUrl:location.origin+location.pathname,title:document.title,referrer:document.referrer||undefined,...a,deviceClass:innerWidth<640?'mobile':innerWidth<1024?'tablet':'desktop',properties:{...a.properties,...properties},...over}]});if(navigator.sendBeacon&&navigator.sendBeacon(endpoint,new Blob([body],{type:'application/json'})))return;fetch(endpoint,{method:'POST',headers:{'content-type':'application/json'},body,keepalive:true,credentials:'omit'}).catch(()=>{})};let visibleSince=document.visibilityState==='visible'?Date.now():0,dwellMs=0,maxScroll=0,dwellPath=location.pathname;const depth=()=>{const d=document.documentElement,h=d.scrollHeight||1;return Math.max(0,Math.min(100,Math.round(((d.scrollTop||document.body.scrollTop||0)+innerHeight)/h*100)))};const accrue=()=>{if(visibleSince){dwellMs+=Date.now()-visibleSince;visibleSince=0}};const resume=()=>{if(!visibleSince)visibleSince=Date.now()};const flush=()=>{accrue();const ms=Math.min(dwellMs,18e5),sd=Math.max(maxScroll,depth());dwellMs=0;maxScroll=0;if(ms<250)return;send('page_engagement',{},{path:dwellPath,canonicalUrl:location.origin+dwellPath,engagedMs:ms,scrollDepth:sd})};addEventListener('scroll',()=>{const d=depth();if(d>maxScroll)maxScroll=d},{passive:true});document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')flush();else resume()});addEventListener('pagehide',flush);const api={page:()=>send('page_view'),track:send,consent:state=>{consent=(state==='granted'||state===true)&&!navigator.globalPrivacyControl;if(consent){ensure();resume();api.page()}},debug:()=>({pixel,endpoint,consent,gpc:Boolean(navigator.globalPrivacyControl),visitorId:visitor,sessionId:session,hostname:location.hostname,path:location.pathname,engagedMs:dwellMs+(visibleSince?Date.now()-visibleSince:0),scrollDepth:Math.max(maxScroll,depth()),...attribution()})};window.mcpAnalytics=api;ensure();if(consent)api.page();let current=location.href;const changed=()=>{if(location.href!==current){flush();current=location.href;dwellPath=location.pathname;resume();api.page()}};for(const method of ['pushState','replaceState']){const original=history[method];history[method]=function(...args){const result=original.apply(this,args);queueMicrotask(changed);return result}}addEventListener('popstate',changed)})();`;
66349
+ }
66350
+ function pagePathFilter(value) {
66351
+ return value && value.startsWith("/") ? value : void 0;
65748
66352
  }
65749
66353
  function reportFilters(url) {
65750
66354
  const end = url.searchParams.get("end") || (/* @__PURE__ */ new Date()).toISOString();
@@ -65766,7 +66370,7 @@ function reportFilters(url) {
65766
66370
  channelFamily: url.searchParams.get("channelFamily") || void 0,
65767
66371
  platform: url.searchParams.get("platform") || void 0,
65768
66372
  referrer: url.searchParams.get("referrer") || void 0,
65769
- path: url.searchParams.get("path") || void 0,
66373
+ path: pagePathFilter(url.searchParams.get("path")),
65770
66374
  deviceClass: url.searchParams.get("deviceClass") || void 0,
65771
66375
  countryCode: url.searchParams.get("countryCode") || void 0,
65772
66376
  regionCode: url.searchParams.get("regionCode") || void 0,
@@ -65902,7 +66506,9 @@ var init_analytics_routes = __esm({
65902
66506
  campaign: import_zod53.z.string().trim().max(240).optional(),
65903
66507
  clickIds: import_zod53.z.record(import_zod53.z.string(), import_zod53.z.string().trim().max(500)).optional(),
65904
66508
  deviceClass: import_zod53.z.enum(["desktop", "tablet", "mobile", "unknown"]).optional(),
65905
- properties: import_zod53.z.record(import_zod53.z.string(), import_zod53.z.unknown()).optional()
66509
+ properties: import_zod53.z.record(import_zod53.z.string(), import_zod53.z.unknown()).optional(),
66510
+ engagedMs: import_zod53.z.number().int().min(0).max(MAX_ENGAGED_MS).optional(),
66511
+ scrollDepth: import_zod53.z.number().int().min(0).max(100).optional()
65906
66512
  }).strict();
65907
66513
  IngestionSchema = import_zod53.z.object({
65908
66514
  pixelId: import_zod53.z.string().trim().min(8).max(160),
@@ -66902,14 +67508,13 @@ Imported through the Site Analytics CRM workflow.
66902
67508
  ["/sites/:siteId/overview", analyticsOverview],
66903
67509
  ["/sites/:siteId/timeseries", analyticsTimeseries],
66904
67510
  ["/sites/:siteId/acquisition", analyticsAcquisition],
66905
- ["/sites/:siteId/content", analyticsContent],
66906
67511
  ["/sites/:siteId/events", analyticsEventCounts]
66907
67512
  ]) {
66908
67513
  analyticsApp.get(path6, auth3, async (c) => {
66909
67514
  const url = new URL(c.req.url);
66910
67515
  const filters = reportFilters(url);
66911
67516
  validateReportWindow(filters);
66912
- const paginated = path6.endsWith("/acquisition") || path6.endsWith("/content") || path6.endsWith("/events");
67517
+ const paginated = path6.endsWith("/acquisition") || path6.endsWith("/events");
66913
67518
  const data = await load(
66914
67519
  c.req.param("siteId"),
66915
67520
  Number(c.get("user").id),
@@ -66919,6 +67524,24 @@ Imported through the Site Analytics CRM workflow.
66919
67524
  return c.json({ ok: true, data, filters });
66920
67525
  });
66921
67526
  }
67527
+ analyticsApp.get("/sites/:siteId/content", auth3, async (c) => {
67528
+ const url = new URL(c.req.url);
67529
+ const filters = reportFilters(url);
67530
+ validateReportWindow(filters);
67531
+ const requestedSort = url.searchParams.get("sort");
67532
+ const data = await analyticsContent(
67533
+ c.req.param("siteId"),
67534
+ Number(c.get("user").id),
67535
+ filters,
67536
+ reportPage(url),
67537
+ {
67538
+ sort: ANALYTICS_CONTENT_SORTS.includes(requestedSort) ? requestedSort : void 0,
67539
+ direction: url.searchParams.get("direction") === "asc" ? "asc" : "desc",
67540
+ search: url.searchParams.get("search") || void 0
67541
+ }
67542
+ );
67543
+ return c.json({ ok: true, data, filters });
67544
+ });
66922
67545
  analyticsApp.get("/sites/:siteId/dashboard", auth3, async (c) => {
66923
67546
  const url = new URL(c.req.url);
66924
67547
  const filters = reportFilters(url);
@@ -67152,7 +67775,7 @@ var init_scheduled_artifact_owner = __esm({
67152
67775
 
67153
67776
  // src/api/scheduled-run-view-links.ts
67154
67777
  async function ensureScheduledRunViewLinksSchema() {
67155
- if (schemaReady3) return;
67778
+ if (schemaReady2) return;
67156
67779
  const db = getDb();
67157
67780
  await db.execute(`
67158
67781
  CREATE TABLE IF NOT EXISTS scheduled_run_view_links (
@@ -67200,7 +67823,7 @@ async function ensureScheduledRunViewLinksSchema() {
67200
67823
  CREATE INDEX IF NOT EXISTS scheduled_run_view_links_expiry
67201
67824
  ON scheduled_run_view_links(expires_at, revoked_at)
67202
67825
  `);
67203
- schemaReady3 = true;
67826
+ schemaReady2 = true;
67204
67827
  }
67205
67828
  function tokenHash2(token4) {
67206
67829
  return (0, import_node_crypto41.createHash)("sha256").update(token4).digest("hex");
@@ -67288,13 +67911,13 @@ async function revokeScheduledRunViewLink(ownerId, runId, shareId, now = /* @__P
67288
67911
  });
67289
67912
  return result.rowsAffected > 0;
67290
67913
  }
67291
- var import_node_crypto41, schemaReady3;
67914
+ var import_node_crypto41, schemaReady2;
67292
67915
  var init_scheduled_run_view_links = __esm({
67293
67916
  "src/api/scheduled-run-view-links.ts"() {
67294
67917
  "use strict";
67295
67918
  import_node_crypto41 = require("crypto");
67296
67919
  init_db();
67297
- schemaReady3 = false;
67920
+ schemaReady2 = false;
67298
67921
  }
67299
67922
  });
67300
67923
 
@@ -67904,9 +68527,9 @@ var init_public_scheduled_result_routes = __esm({
67904
68527
  // src/api/service-connections.ts
67905
68528
  function ensureServiceConnectionsSchema() {
67906
68529
  const currentDb = getDb();
67907
- if (schemaReady4 && schemaDb4 === currentDb) return schemaReady4;
68530
+ if (schemaReady3 && schemaDb4 === currentDb) return schemaReady3;
67908
68531
  schemaDb4 = currentDb;
67909
- schemaReady4 = (async () => {
68532
+ schemaReady3 = (async () => {
67910
68533
  const db = currentDb;
67911
68534
  await db.execute(`
67912
68535
  CREATE TABLE IF NOT EXISTS service_connections (
@@ -67974,11 +68597,11 @@ function ensureServiceConnectionsSchema() {
67974
68597
  } catch {
67975
68598
  }
67976
68599
  })().catch((error) => {
67977
- schemaReady4 = null;
68600
+ schemaReady3 = null;
67978
68601
  schemaDb4 = null;
67979
68602
  throw error;
67980
68603
  });
67981
- return schemaReady4;
68604
+ return schemaReady3;
67982
68605
  }
67983
68606
  function parseTools(value) {
67984
68607
  if (typeof value !== "string") return [];
@@ -68177,13 +68800,13 @@ async function claimServiceConnectionAction(args) {
68177
68800
  ...result !== void 0 ? { result } : {}
68178
68801
  };
68179
68802
  }
68180
- var import_node_crypto45, schemaReady4, schemaDb4;
68803
+ var import_node_crypto45, schemaReady3, schemaDb4;
68181
68804
  var init_service_connections = __esm({
68182
68805
  "src/api/service-connections.ts"() {
68183
68806
  "use strict";
68184
68807
  import_node_crypto45 = require("crypto");
68185
68808
  init_db();
68186
- schemaReady4 = null;
68809
+ schemaReady3 = null;
68187
68810
  schemaDb4 = null;
68188
68811
  }
68189
68812
  });
@@ -73113,7 +73736,31 @@ async function chargeTierChangeNow(stripeClient, subscriptionId, customerId) {
73113
73736
  return { ok: false, amountDue: 0, error: err instanceof Error ? err.message : "Unable to charge the plan change immediately." };
73114
73737
  }
73115
73738
  }
73116
- var import_resend3, import_node_crypto55, import_hono36, import_hono37, import_factory8, import_cookie2, import_stripe2, secureCookies2, isProduction2, sessionCookieOptions2, requireAllowedOrigin, auth4, sessionAuth, requireIntegrationsTier, requirePaidSchedulingTier, app, deploymentProfile, STRIPE_API_VERSION, SYNC_HARVEST_TIMEOUT_OVERRIDE_MS;
73739
+ function settleWithinTickBudget(label, unfinished, work, onDeadlineOrError) {
73740
+ return new Promise((resolve2) => {
73741
+ const timer = setTimeout(() => {
73742
+ unfinished.push(`${label}:deadline`);
73743
+ resolve2(onDeadlineOrError);
73744
+ }, CRON_TICK_BUDGET_MS);
73745
+ work.then(
73746
+ (value) => {
73747
+ clearTimeout(timer);
73748
+ resolve2(value);
73749
+ },
73750
+ (err) => {
73751
+ clearTimeout(timer);
73752
+ unfinished.push(`${label}:error`);
73753
+ console.error(JSON.stringify({
73754
+ event: "cron_tick_job_failed",
73755
+ job: label,
73756
+ message: err instanceof Error ? err.message : String(err)
73757
+ }));
73758
+ resolve2(onDeadlineOrError);
73759
+ }
73760
+ );
73761
+ });
73762
+ }
73763
+ var import_resend3, import_node_crypto55, import_hono36, import_hono37, import_factory8, import_cookie2, import_stripe2, secureCookies2, isProduction2, sessionCookieOptions2, requireAllowedOrigin, auth4, sessionAuth, requireIntegrationsTier, requirePaidSchedulingTier, app, deploymentProfile, STRIPE_API_VERSION, SYNC_HARVEST_TIMEOUT_OVERRIDE_MS, CRON_TICK_BUDGET_MS, CRON_TICK_DRAIN_BUDGET_MS;
73117
73764
  var init_server = __esm({
73118
73765
  "src/api/server.ts"() {
73119
73766
  "use strict";
@@ -73167,6 +73814,8 @@ var init_server = __esm({
73167
73814
  init_directory_workflow_dispatch();
73168
73815
  init_paa_harvest_dispatch();
73169
73816
  init_paa_harvest_reconciliation();
73817
+ init_unified_billing();
73818
+ init_retention_sweeps();
73170
73819
  init_directory_workflow_reconciliation();
73171
73820
  init_directory_artifacts();
73172
73821
  init_workflow_routes();
@@ -75858,35 +76507,49 @@ var init_server = __esm({
75858
76507
  ledger
75859
76508
  });
75860
76509
  });
76510
+ CRON_TICK_BUDGET_MS = 5e4;
76511
+ CRON_TICK_DRAIN_BUDGET_MS = 4e4;
75861
76512
  app.get("/cron/tick", async (c) => {
75862
76513
  const secret2 = c.req.header("authorization");
75863
76514
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
75864
76515
  return c.json({ error: "Unauthorized" }, 401);
75865
76516
  }
76517
+ const startedAt = Date.now();
75866
76518
  const { drainQueue: drainQueue2 } = await Promise.resolve().then(() => (init_worker(), worker_exports));
75867
- const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
75868
- const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
75869
- const [results, sweepResult, reapResult, expiredResult, blobCleanup, connectedDataArtifactCleanup, connectedAccountBilling, directoryWorkflowDispatch, directoryWorkflowReconciliation, directoryArtifactCleanup, siteExtractArtifactCleanup, siteExtractRedispatch, siteExtractReconciliation, paaHarvestRedispatch, paaHarvestReconciliation, localSourcebookRedispatch, analyticsRollup, analyticsFormDelivery] = await Promise.all([
75870
- drainQueue2(budget),
75871
- runMonthlyRefreshSweep(),
75872
- reapIdleBrowserSessions(120).catch(() => ({ reaped: 0 })),
75873
- expireOldLots().catch(() => ({ expired_lots: 0, expired_mc: 0 })),
75874
- cleanupExpiredScrapeBlobs().catch(() => ({ deleted: 0, store: "none" })),
75875
- cleanupExpiredConnectedDataArtifacts().catch(() => ({ deleted: 0, store: "none" })),
75876
- reconcileConnectedAccountBillingSweep().catch(() => ({ checked: 0, synced: 0, failed: 0 })),
75877
- dispatchPendingDirectoryWorkflows(25).catch(() => ({ claimed: 0, dispatched: 0, failed: 0 })),
75878
- reconcileDirectoryWorkflowSettlements(25).catch(() => ({ checked: 0, settled: 0, failed: 0 })),
75879
- cleanupExpiredDirectoryArtifacts().catch(() => ({ deleted: 0, store: "none" })),
75880
- cleanupExpiredSiteExtractArtifacts().catch(() => ({ deleted: 0, store: "none" })),
75881
- redispatchFundedSiteExtractJobs(25).catch(() => ({ checked: 0, dispatched: 0, failed: 0 })),
75882
- reconcileSiteExtractSettlements(25).catch(() => ({ checked: 0, settled: 0, failed: 0 })),
75883
- redispatchPendingPaaHarvests(25).catch(() => ({ checked: 0, dispatched: 0, failed: 0 })),
75884
- reconcilePaaHarvestSettlements(25).catch(() => ({ checked: 0, staleFailed: 0, settled: 0, failed: 0 })),
75885
- redispatchQueuedLocalSourcebookAcquisitions(25).catch(() => ({ checked: 0, dispatched: 0, failed: 0 })),
75886
- refreshAnalyticsDailyRollupsIfDue().catch(() => ({ status: "failed", rows: 0 })),
75887
- deliverPendingAnalyticsForms(10).catch(() => ({ claimed: 0, delivered: 0, deferred: 0 }))
76519
+ const budget = { maxJobs: 10, deadlineMs: startedAt + CRON_TICK_DRAIN_BUDGET_MS };
76520
+ const origin = `${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`;
76521
+ const unfinished = [];
76522
+ const [results, sweepResult, reapResult, expiredResult, blobCleanup, connectedDataArtifactCleanup, connectedAccountBilling, directoryWorkflowDispatch, directoryWorkflowReconciliation, directoryArtifactCleanup, siteExtractArtifactCleanup, siteExtractRedispatch, siteExtractReconciliation, paaHarvestRedispatch, paaHarvestReconciliation, localSourcebookRedispatch, analyticsRollup, analyticsFormDelivery, workflowDispatchResult, scheduledRunReconciliation, retentionSweep] = await Promise.all([
76523
+ settleWithinTickBudget("drainQueue", unfinished, drainQueue2(budget), []),
76524
+ settleWithinTickBudget("monthlyRefreshSweep", unfinished, runMonthlyRefreshSweep(), { status: "skipped_deadline" }),
76525
+ settleWithinTickBudget("reapBrowserSessions", unfinished, reapIdleBrowserSessions(120), { reaped: 0 }),
76526
+ settleWithinTickBudget("retryOrphanedBrowserSessions", unfinished, retryOrphanedBrowserSessions(50), { recovered: 0, stillOpen: 0 }),
76527
+ settleWithinTickBudget("expireLots", unfinished, expireOldLots(), { expired_lots: 0, expired_mc: 0 }),
76528
+ settleWithinTickBudget("scrapeBlobCleanup", unfinished, cleanupExpiredScrapeBlobs(), { deleted: 0, store: "none" }),
76529
+ settleWithinTickBudget("connectedDataCleanup", unfinished, cleanupExpiredConnectedDataArtifacts(), { deleted: 0, store: "none" }),
76530
+ settleWithinTickBudget("connectedBillingSweep", unfinished, reconcileConnectedAccountBillingSweep(), { checked: 0, synced: 0, failed: 0 }),
76531
+ settleWithinTickBudget("directoryDispatch", unfinished, dispatchPendingDirectoryWorkflows(5), { claimed: 0, dispatched: 0, failed: 0 }),
76532
+ settleWithinTickBudget("directoryReconcile", unfinished, reconcileDirectoryWorkflowSettlements(5), { checked: 0, settled: 0, failed: 0 }),
76533
+ settleWithinTickBudget("directoryArtifactCleanup", unfinished, cleanupExpiredDirectoryArtifacts(), { deleted: 0, store: "none" }),
76534
+ settleWithinTickBudget("siteExtractArtifactCleanup", unfinished, cleanupExpiredSiteExtractArtifacts(), { deleted: 0, store: "none" }),
76535
+ settleWithinTickBudget("siteExtractRedispatch", unfinished, redispatchFundedSiteExtractJobs(10), { checked: 0, dispatched: 0, failed: 0 }),
76536
+ settleWithinTickBudget("siteExtractReconcile", unfinished, reconcileSiteExtractSettlements(10), { checked: 0, settled: 0, failed: 0 }),
76537
+ settleWithinTickBudget("paaRedispatch", unfinished, redispatchPendingPaaHarvests(10), { checked: 0, dispatched: 0, failed: 0 }),
76538
+ settleWithinTickBudget("paaReconcile", unfinished, reconcilePaaHarvestSettlements(10), { checked: 0, staleFailed: 0, settled: 0, failed: 0 }),
76539
+ settleWithinTickBudget("localSourcebookRedispatch", unfinished, redispatchQueuedLocalSourcebookAcquisitions(5), { checked: 0, dispatched: 0, failed: 0 }),
76540
+ settleWithinTickBudget("analyticsRollup", unfinished, refreshAnalyticsDailyRollupsIfDue(), { status: "failed", rows: 0 }),
76541
+ settleWithinTickBudget("analyticsForms", unfinished, deliverPendingAnalyticsForms(10), { claimed: 0, delivered: 0, deferred: 0 }),
76542
+ settleWithinTickBudget("workflowDispatch", unfinished, dispatchDueWorkflowSchedules(origin), { dispatched: 0, status: "skipped_deadline" }),
76543
+ settleWithinTickBudget("scheduledRunReconcile", unfinished, reconcileScheduledRunSettlements(25), { checked: 0, settled: 0, chargedMc: 0, failed: 0 }),
76544
+ settleWithinTickBudget("retentionSweep", unfinished, sweepRetention(), { deleted: 0, perTable: {}, truncated: false })
75888
76545
  ]);
75889
76546
  return c.json({
76547
+ durationMs: Date.now() - startedAt,
76548
+ tickBudgetMs: CRON_TICK_BUDGET_MS,
76549
+ unfinished,
76550
+ complete: unfinished.length === 0,
76551
+ scheduledRunReconciliation,
76552
+ retentionSweep,
75890
76553
  drained: results.length,
75891
76554
  results,
75892
76555
  sweepResult,