@vibes.diy/api-svc 12.2.11 → 12.3.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 (43) hide show
  1. package/cf-serve.js +29 -19
  2. package/cf-serve.js.map +1 -1
  3. package/index.d.ts +1 -0
  4. package/index.js +1 -0
  5. package/index.js.map +1 -1
  6. package/intern/first-message-embedding.js +2 -2
  7. package/intern/first-message-embedding.js.map +1 -1
  8. package/intern/llm-tool-loop.d.ts +19 -0
  9. package/intern/llm-tool-loop.js +70 -49
  10. package/intern/llm-tool-loop.js.map +1 -1
  11. package/intern/prompt-assembly.js +4 -2
  12. package/intern/prompt-assembly.js.map +1 -1
  13. package/intern/work-tools.d.ts +24 -2
  14. package/intern/work-tools.js +44 -9
  15. package/intern/work-tools.js.map +1 -1
  16. package/intern/work-turn-status.d.ts +19 -0
  17. package/intern/work-turn-status.js +53 -0
  18. package/intern/work-turn-status.js.map +1 -0
  19. package/package.json +11 -11
  20. package/public/cold-pull-timing.d.ts +1 -0
  21. package/public/cold-pull-timing.js +1 -1
  22. package/public/cold-pull-timing.js.map +1 -1
  23. package/public/get-application-chat.js +2 -2
  24. package/public/get-application-chat.js.map +1 -1
  25. package/public/get-chat-response.js +2 -2
  26. package/public/get-chat-response.js.map +1 -1
  27. package/public/get-vibe-chips.js +2 -2
  28. package/public/get-vibe-chips.js.map +1 -1
  29. package/public/llm-dispatch.d.ts +1 -1
  30. package/public/prompt-chat-section.js +72 -16
  31. package/public/prompt-chat-section.js.map +1 -1
  32. package/public/stream-assembly.d.ts +14 -1
  33. package/public/stream-assembly.js +120 -2
  34. package/public/stream-assembly.js.map +1 -1
  35. package/sessions-dispatch-timing.d.ts +11 -1
  36. package/sessions-dispatch-timing.js +22 -8
  37. package/sessions-dispatch-timing.js.map +1 -1
  38. package/svc-ws-send-provider.js +5 -1
  39. package/svc-ws-send-provider.js.map +1 -1
  40. package/usage-report/check-env.sh +188 -0
  41. package/usage-report/user-journey.js +29 -3
  42. package/usage-report/user-journey.js.map +1 -1
  43. package/usage-report/vibe-usage.py +117 -53
@@ -15,12 +15,19 @@ Usage:
15
15
  OWNER=owner APP_SLUG=my-app CF_PATH='/vibe/owner/my-app%' python3 vibe-usage.py 168
16
16
 
17
17
  Requires env (present in cloud sessions): CLOUDFLARE_API_TOKEN, CLERK_SECRET_KEY.
18
+ Both are ENFORCED — a missing key exits 2 rather than zeroing a leg of the
19
+ funnel. Check with usage-report/check-env.sh --tool vibe-usage; opt out of a
20
+ leg deliberately with --skip-visitors / --skip-signups.
18
21
  Postgres goes through the repo's db:inspect (prod, read-only).
19
22
  """
20
23
  import json, os, subprocess, sys, urllib.request, urllib.error
21
24
  from datetime import datetime, timedelta, timezone
22
25
 
23
- HOURS = int(sys.argv[1]) if len(sys.argv) > 1 else 48
26
+ _FLAGS = {a for a in sys.argv[1:] if a.startswith("--")}
27
+ _POSITIONAL = [a for a in sys.argv[1:] if not a.startswith("--")]
28
+ HOURS = int(_POSITIONAL[0]) if _POSITIONAL else 48
29
+ SKIP_VISITORS = "--skip-visitors" in _FLAGS
30
+ SKIP_SIGNUPS = "--skip-signups" in _FLAGS
24
31
  # App identity in Postgres is keyed by (userSlug, appSlug) — filtering on appSlug
25
32
  # alone would aggregate dev/remix/test twins under other handles. OWNER scopes it
26
33
  # to the one owner whose /vibe/<owner>/<slug> path the CF_PATH also targets.
@@ -38,9 +45,46 @@ since_iso = since.strftime("%Y-%m-%dT%H:%M:%SZ")
38
45
  now_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
39
46
  since_ms = int(since.timestamp() * 1000)
40
47
 
48
+ def load_dev_vars():
49
+ """Mirror loadDevVars() in usage-report-util.ts: api/svc/.dev.vars is a
50
+ supported source for these keys, so the TS tools, check-env.sh and this
51
+ script must all agree on what 'set' means. First occurrence wins, quotes
52
+ are stripped, and a real environment variable always takes precedence."""
53
+ for path in (os.path.join(DB_DIR, ".dev.vars"), ".dev.vars"):
54
+ if not os.path.exists(path):
55
+ continue
56
+ with open(path, encoding="utf8") as fh:
57
+ for raw in fh:
58
+ line = raw.strip()
59
+ if not line or line.startswith("#") or "=" not in line:
60
+ continue
61
+ key, _, value = line.partition("=")
62
+ key, value = key.strip(), value.strip()
63
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
64
+ value = value[1:-1]
65
+ os.environ.setdefault(key, value)
66
+
67
+
68
+ load_dev_vars()
41
69
  CF_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN")
42
70
  CLERK_KEY = os.environ.get("CLERK_SECRET_KEY")
43
71
 
72
+ # A missing key must not silently zero a leg of the funnel. Without CF_TOKEN the
73
+ # requests go out as "Bearer None", the HTTPError is swallowed, the counters stay
74
+ # at their zero initialisers, and the last line reads "FUNNEL: ~0 visits" — a
75
+ # plausible number about a dead app rather than a visible failure. Fail closed;
76
+ # --skip-visitors / --skip-signups opt out explicitly and stamp the funnel.
77
+ _missing = []
78
+ if not CF_TOKEN and not SKIP_VISITORS:
79
+ _missing.append("CLOUDFLARE_API_TOKEN (visitor traffic; without it the funnel would read ~0 visits)")
80
+ if not CLERK_KEY and not SKIP_SIGNUPS:
81
+ _missing.append("CLERK_SECRET_KEY (sign-ups; without it the funnel would read 0 sign-ups)")
82
+ if _missing:
83
+ print("refusing to run — missing credentials:\n " + "\n ".join(_missing), file=sys.stderr)
84
+ print("\ncheck your env: usage-report/check-env.sh --tool vibe-usage"
85
+ "\nor accept a partial funnel with --skip-visitors / --skip-signups", file=sys.stderr)
86
+ sys.exit(2)
87
+
44
88
 
45
89
  def db(sql):
46
90
  """Run a read-only query via db:inspect, return parsed rows."""
@@ -109,63 +153,83 @@ print(f" writers new-to-app={split['new']} returning={split['returning']}")
109
153
  # ---- 2. Cloudflare: read-only visitors (day-sliced; zone caps at 1d) ------
110
154
  print("\nTRAFFIC (all visitors, incl. non-signed-in; sampled + IP counts overlap days)")
111
155
  tot_ip = tot_visits = tot_req = 0
112
- slice_start = since
113
- while slice_start < now:
114
- slice_end = min(slice_start + timedelta(hours=24), now)
115
- q = f'''query {{ viewer {{ zones(filter: {{zoneTag: "{CF_ZONE}"}}) {{
116
- httpRequestsAdaptiveGroups(limit: 5000, filter: {{
117
- datetime_geq: "{slice_start.strftime('%Y-%m-%dT%H:%M:%SZ')}",
118
- datetime_leq: "{slice_end.strftime('%Y-%m-%dT%H:%M:%SZ')}",
119
- clientRequestPath_like: "{CF_PATH}"}}) {{
120
- dimensions {{ clientIP }} sum {{ visits }} count }} }} }} }}'''
121
- res = cf_graphql(q)
122
- if res.get("errors") or res.get("_error"):
123
- print(f" [{slice_start:%m-%d %H:%M}] cf error: {res.get('errors') or res.get('_error')}")
124
- else:
125
- rows = res["data"]["viewer"]["zones"][0]["httpRequestsAdaptiveGroups"]
126
- ips = len(rows); v = sum(r["sum"]["visits"] for r in rows); c = sum(r["count"] for r in rows)
127
- tot_ip += ips; tot_visits += v; tot_req += c
128
- print(f" [{slice_start:%m-%d %H:%M}→{slice_end:%H:%M}] IPs={ips} visits={v} requests={c}")
129
- slice_start = slice_end
130
- print(f" TOTAL: IPs={tot_ip} (summed per-slice — cross-day overlap, not exact uniques)"
131
- f" visits={tot_visits} requests={tot_req}")
156
+ cf_failed = False
157
+ if SKIP_VISITORS:
158
+ print(" SKIPPED (--skip-visitors) — Cloudflare was never queried. The zeros below mean")
159
+ print(" 'not measured', not 'no traffic'.")
160
+ else:
161
+ slice_start = since
162
+ while slice_start < now:
163
+ slice_end = min(slice_start + timedelta(hours=24), now)
164
+ q = f'''query {{ viewer {{ zones(filter: {{zoneTag: "{CF_ZONE}"}}) {{
165
+ httpRequestsAdaptiveGroups(limit: 5000, filter: {{
166
+ datetime_geq: "{slice_start.strftime('%Y-%m-%dT%H:%M:%SZ')}",
167
+ datetime_leq: "{slice_end.strftime('%Y-%m-%dT%H:%M:%SZ')}",
168
+ clientRequestPath_like: "{CF_PATH}"}}) {{
169
+ dimensions {{ clientIP }} sum {{ visits }} count }} }} }} }}'''
170
+ res = cf_graphql(q)
171
+ if res.get("errors") or res.get("_error"):
172
+ cf_failed = True
173
+ print(f" [{slice_start:%m-%d %H:%M}] cf error: {res.get('errors') or res.get('_error')}")
174
+ else:
175
+ rows = res["data"]["viewer"]["zones"][0]["httpRequestsAdaptiveGroups"]
176
+ ips = len(rows); v = sum(r["sum"]["visits"] for r in rows); c = sum(r["count"] for r in rows)
177
+ tot_ip += ips; tot_visits += v; tot_req += c
178
+ print(f" [{slice_start:%m-%d %H:%M}→{slice_end:%H:%M}] IPs={ips} visits={v} requests={c}")
179
+ slice_start = slice_end
180
+ print(f" TOTAL: IPs={tot_ip} (summed per-slice — cross-day overlap, not exact uniques)"
181
+ f" visits={tot_visits} requests={tot_req}")
182
+ # A slice that errored contributes 0, so a partly-failed run must not present
183
+ # its total as a measurement.
184
+ if cf_failed:
185
+ print(" ⚠ at least one slice errored — the total above is a FLOOR, not a count.")
132
186
 
133
187
  # ---- 3. Clerk: sign-ups + baseline ---------------------------------------
134
188
  print("\nSIGN-UPS (Clerk, platform-wide)")
135
- total = clerk("/users/count").get("total_count", "?")
136
- active = clerk(f"/users/count?last_active_at_since={since_ms}").get("total_count", "?")
137
- # Page back until we pass the baseline window's start, else the newest 200 would
138
- # silently cap `new`/`prior` during exactly the spike/long-window runs this targets.
139
- base_start = since_ms - 14 * 86400000
140
- users, offset, capped = [], 0, False
141
- while True:
142
- page = clerk(f"/users?order_by=-created_at&limit=500&offset={offset}")
143
- if not isinstance(page, list) or not page:
144
- break
145
- users += page
146
- if page[-1]["created_at"] < base_start: # covered the whole window+baseline
147
- break
148
- offset += 500
149
- if offset >= 5000: # 10k-account safety stop
150
- capped = True
151
- break
152
- new = [u for u in users if u["created_at"] >= since_ms]
153
- prior = [u for u in users if base_start <= u["created_at"] < since_ms]
154
- base_per = len(prior) / (14 / (HOURS / 24))
155
- print(f" total accounts(cumulative)={total} active-in-window={active}")
156
- cap_note = " ⚠ paging capped at 10k new/baseline may undercount" if capped else ""
157
- print(f" new sign-ups={len(new)} (prior baseline ≈{base_per:.1f} per {HOURS}h){cap_note}")
158
- doms = {}
159
- for u in new:
160
- d = (u["email_addresses"][0]["email_address"].split("@")[-1]
161
- if u.get("email_addresses") else "?")
162
- doms[d] = doms.get(d, 0) + 1
163
- if doms:
164
- print(" domains: " + ", ".join(f"{k}={v}" for k, v in sorted(doms.items(), key=lambda x: -x[1])))
189
+ if SKIP_SIGNUPS:
190
+ print(" SKIPPED (--skip-signups) — Clerk was never queried; sign-ups are UNKNOWN, not 0.")
191
+ new = []
192
+ else:
193
+ total = clerk("/users/count").get("total_count", "?")
194
+ active = clerk(f"/users/count?last_active_at_since={since_ms}").get("total_count", "?")
195
+ # Page back until we pass the baseline window's start, else the newest 200 would
196
+ # silently cap `new`/`prior` during exactly the spike/long-window runs this targets.
197
+ base_start = since_ms - 14 * 86400000
198
+ users, offset, capped = [], 0, False
199
+ while True:
200
+ page = clerk(f"/users?order_by=-created_at&limit=500&offset={offset}")
201
+ if not isinstance(page, list) or not page:
202
+ break
203
+ users += page
204
+ if page[-1]["created_at"] < base_start: # covered the whole window+baseline
205
+ break
206
+ offset += 500
207
+ if offset >= 5000: # 10k-account safety stop
208
+ capped = True
209
+ break
210
+ new = [u for u in users if u["created_at"] >= since_ms]
211
+ prior = [u for u in users if base_start <= u["created_at"] < since_ms]
212
+ base_per = len(prior) / (14 / (HOURS / 24))
213
+ print(f" total accounts(cumulative)={total} active-in-window={active}")
214
+ cap_note = " ⚠ paging capped at 10k — new/baseline may undercount" if capped else ""
215
+ print(f" new sign-ups={len(new)} (prior baseline ≈{base_per:.1f} per {HOURS}h){cap_note}")
216
+ doms = {}
217
+ for u in new:
218
+ d = (u["email_addresses"][0]["email_address"].split("@")[-1]
219
+ if u.get("email_addresses") else "?")
220
+ doms[d] = doms.get(d, 0) + 1
221
+ if doms:
222
+ print(" domains: " + ", ".join(f"{k}={v}" for k, v in sorted(doms.items(), key=lambda x: -x[1])))
165
223
 
166
224
  # funnel cross-ref: which new Clerk accounts wrote in the app
167
225
  writers = {r["userId"] for r in db(f'''SELECT DISTINCT "userId" FROM "AppDocuments"
168
226
  WHERE {APP_FILTER} AND created >= '{since_iso}' ''')}
169
227
  new_ids = {u["id"] for u in new}
170
- print(f"\nFUNNEL: ~{tot_visits} visits {len(new)} sign-ups → "
171
- f"{len(writers)} active writers ({len(writers & new_ids)} of them brand-new accounts)")
228
+ visits_txt = "UNKNOWN" if SKIP_VISITORS else (f"~{tot_visits}+" if cf_failed else f"~{tot_visits}")
229
+ signups_txt = "UNKNOWN" if SKIP_SIGNUPS else str(len(new))
230
+ # The cross-reference is derived from `new`, so skipping Clerk makes it
231
+ # unmeasured too — printing "0 of them brand-new" would be the same
232
+ # plausible-negative this whole change exists to remove.
233
+ new_txt = "UNKNOWN" if SKIP_SIGNUPS else str(len(writers & new_ids))
234
+ print(f"\nFUNNEL: {visits_txt} visits → {signups_txt} sign-ups → "
235
+ f"{len(writers)} active writers ({new_txt} of them brand-new accounts)")