sizmo 0.4.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.
@@ -0,0 +1,256 @@
1
+ // commands/snapshot.mjs — Monday card: 6 metrics, one screen.
2
+ // Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
3
+ // Trust-fix #2: leads() and revenue() paginate to completion.
4
+ // Trust-fix #3: revenue tracks per-currency (never cross-sums).
5
+ import { paginate } from '../lib/paginate.mjs';
6
+ import { mapLimit } from '../lib/pool.mjs';
7
+
8
+ export const meta = {
9
+ name: 'snapshot',
10
+ summary: 'Monday card — 6 metrics, one screen',
11
+ flags: [{ name: '--days', type: 'int', default: 7, desc: 'window in days' }],
12
+ readOnly: true,
13
+ };
14
+
15
+ const SYM = { PHP: '₱', USD: '$', EUR: '€', GBP: '£', AUD: 'A$', CAD: 'C$' };
16
+ const money = (n, cur = 'PHP') =>
17
+ (n == null || !Number.isFinite(Number(n))) ? '—' : (SYM[cur] || cur + ' ') + Number(n).toLocaleString('en-PH', { maximumFractionDigits: 0 });
18
+ const fmtManila = (ms) =>
19
+ new Date(ms).toLocaleString('en-US', { timeZone: 'Asia/Manila', month: 'short', day: 'numeric' });
20
+ const metric = (label, value, { note = '', blocked = false, blocker = '' } = {}) =>
21
+ ({ label, value, note, blocked, blocker });
22
+
23
+ export async function collect(args, ctx) {
24
+ const DAYS = args.days != null ? args.days : (Number(args._?.[0]) || 7);
25
+ const LOC = ctx.cfg.loc;
26
+ const NOW = ctx.now;
27
+ const START = NOW - DAYS * 24 * 60 * 60 * 1000;
28
+ const startISO = new Date(START).toISOString();
29
+ const inWindow = (v) => {
30
+ const t = typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : Date.parse(v);
31
+ return Number.isFinite(t) && t >= START && t <= NOW;
32
+ };
33
+
34
+ // ── LEADS: paginate contacts newest→older, stop past window ──
35
+ async function leads() {
36
+ let count = 0, pages = 0, oldest = null;
37
+ let startAfter, startAfterId, done = false;
38
+ for await (const c of paginate({
39
+ fetchPage: async (cursor) => {
40
+ const q = { locationId: LOC, limit: 100 };
41
+ if (cursor) { q.startAfter = cursor.startAfter; q.startAfterId = cursor.startAfterId; }
42
+ const r = await ctx.http.get('/contacts/', { query: q });
43
+ if (!r.ok) return { _err: r.code, contacts: [] };
44
+ return r.j;
45
+ },
46
+ getItems: (resp) => {
47
+ if (resp._err) return [];
48
+ const arr = resp.contacts || resp.data || [];
49
+ if (!arr.length) done = true;
50
+ for (const c of arr) {
51
+ const da = c.dateAdded || c.createdAt;
52
+ const t = Date.parse(da);
53
+ if (Number.isFinite(t)) { oldest = t; if (t < START) done = true; }
54
+ }
55
+ pages++;
56
+ const last = arr[arr.length - 1];
57
+ startAfter = last?.dateAdded ? Date.parse(last.dateAdded) : undefined;
58
+ startAfterId = last?.id;
59
+ return arr;
60
+ },
61
+ nextCursor: (resp, items) => {
62
+ if (done || items.length < 100 || resp._err) return null;
63
+ return { startAfter, startAfterId };
64
+ },
65
+ maxPages: 200,
66
+ })) {
67
+ const da = c.dateAdded || c.createdAt;
68
+ const t = Date.parse(da);
69
+ if (Number.isFinite(t) && t >= START && t <= NOW) count++;
70
+ }
71
+ if (pages === 0) return metric('Leads', null, { blocked: true, blocker: 'contacts read failed' });
72
+ return metric('Leads', count, { note: `new contacts · ${pages} page(s) scanned` });
73
+ }
74
+
75
+ // ── BOOKINGS + SHOW RATE ──
76
+ // I-2 truncation cap: GHL's /calendars/events has no pagination cursor.
77
+ // If a calendar returns >= CAP events it is likely truncated (silently under-reports).
78
+ // Full fix = date-window splitting; tracked as follow-up. Cheap mitigation: warn + degrade.
79
+ const EVENTS_CAP = 100;
80
+ async function bookings() {
81
+ const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
82
+ if (!cr.ok) return [
83
+ metric('Bookings', null, { blocked: true, blocker: `calendars list HTTP ${cr.code}` }),
84
+ metric('Show rate', null, { blocked: true, blocker: 'no calendars' }),
85
+ ];
86
+ const cals = cr.j.calendars || [];
87
+ let booked = 0, showed = 0, noshow = 0, calsHit = 0, skippedCalendars = 0;
88
+ // Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
89
+ // ONLY the independent per-calendar fetches are parallelized — pagination pages stay sequential.
90
+ const evResults = await mapLimit(cals, 5, async (cal) => {
91
+ const ev = await ctx.http.get('/calendars/events', {
92
+ query: { locationId: LOC, calendarId: cal.id, startTime: String(START), endTime: String(NOW) },
93
+ version: '2021-04-15',
94
+ });
95
+ return { cal, ev };
96
+ });
97
+ for (const { cal, ev } of evResults) {
98
+ if (!ev.ok) {
99
+ skippedCalendars++;
100
+ ctx.out.warn(`calendar "${cal.name || cal.id}" events unreadable (HTTP ${ev.code})`, { degraded: true });
101
+ continue;
102
+ }
103
+ const evList = ev.j.events || ev.j.appointments || [];
104
+ // I-2: truncation mitigation — no cursor available; warn if at cap
105
+ if (evList.length >= EVENTS_CAP) {
106
+ ctx.out.warn(`calendar "${cal.name || cal.id}" returned ${evList.length} events — may be truncated (no pagination cursor available); counts for this calendar may under-report`, { degraded: true });
107
+ }
108
+ calsHit++;
109
+ for (const e of evList) {
110
+ const st = e.startTime || e.startTimeISO || e.appointmentStartTime;
111
+ if (!inWindow(st)) continue;
112
+ booked++;
113
+ const s = (e.appointmentStatus || e.status || '').toLowerCase();
114
+ if (s === 'showed' || s === 'shown') showed++;
115
+ else if (s === 'noshow' || s === 'no-show' || s === 'no_show') noshow++;
116
+ }
117
+ }
118
+ const rated = showed + noshow;
119
+ const showRate = rated > 0 ? Math.round(showed / rated * 100) : null;
120
+ return [
121
+ metric('Bookings', booked, { note: `appointments · ${calsHit}/${cals.length} calendars${skippedCalendars > 0 ? ` · ${skippedCalendars} skipped` : ''}`, ...(skippedCalendars > 0 && { skippedCalendars }) }),
122
+ showRate == null
123
+ ? metric('Show rate', null, { blocked: true, blocker: 'no completed (showed/noshow) appts in window yet' })
124
+ : metric('Show rate', showRate + '%', { note: `${showed} showed / ${rated} completed` }),
125
+ ];
126
+ }
127
+
128
+ // ── REVENUE: paginate transactions to completion (trust-fix #2), per-currency (trust-fix #3) ──
129
+ async function revenue() {
130
+ const byCur = {}; // { PHP: { sum, n }, ... }
131
+ let firstErr = null, totalScanned = 0;
132
+ for await (const t of paginate({
133
+ fetchPage: async (offset = 0) => {
134
+ const r = await ctx.http.get('/payments/transactions', {
135
+ query: { altId: LOC, altType: 'location', limit: 100, offset },
136
+ });
137
+ if (!r.ok) return { _err: r.code, data: [] };
138
+ return r.j;
139
+ },
140
+ getItems: (resp) => {
141
+ if (resp._err) { firstErr = resp._err; return []; }
142
+ return resp.data || resp.transactions || [];
143
+ },
144
+ nextCursor: (resp, items, offset = 0) => {
145
+ if (resp._err || items.length < 100) return null;
146
+ return offset + 100;
147
+ },
148
+ maxPages: 200,
149
+ startCursor: 0,
150
+ })) {
151
+ totalScanned++;
152
+ const when = t.createdAt || t.created_at || t.dateAdded;
153
+ const ok = (t.status || t.paymentStatus || '').toLowerCase();
154
+ if (inWindow(when) && (ok === 'succeeded' || ok === 'success' || ok === 'paid' || ok === 'completed' || ok === 'captured')) {
155
+ const cur = (t.currency || 'PHP').toUpperCase();
156
+ byCur[cur] = byCur[cur] || { sum: 0, n: 0 };
157
+ byCur[cur].sum += Number(t.amount) || 0;
158
+ byCur[cur].n++;
159
+ }
160
+ }
161
+ if (firstErr && totalScanned === 0)
162
+ return metric('Collected', null, { blocked: true, blocker: `transactions HTTP ${firstErr}` });
163
+ // If only one currency, match original format; multi-currency → list all
164
+ const entries = Object.entries(byCur);
165
+ if (entries.length === 0)
166
+ return metric('Collected', money(0, 'PHP'), { note: `0 payment(s) · ${totalScanned} txns scanned` });
167
+ if (entries.length === 1) {
168
+ const [cur, { sum, n }] = entries[0];
169
+ return metric('Collected', money(sum, cur), { note: `${n} payment(s) · ${totalScanned} txns scanned` });
170
+ }
171
+ const summary = entries.map(([c, { sum, n }]) => `${money(sum, c)} (${n})`).join(' + ');
172
+ const totalN = entries.reduce((s, [, { n }]) => s + n, 0);
173
+ return metric('Collected', summary, { note: `${totalN} payment(s) · ${totalScanned} txns scanned · multi-currency` });
174
+ }
175
+
176
+ // ── PIPELINE VALUE ──
177
+ async function pipelineValue() {
178
+ let sum = 0, n = 0;
179
+ for await (const o of paginate({
180
+ fetchPage: async (page = 1) => {
181
+ const r = await ctx.http.get('/opportunities/search', {
182
+ query: { location_id: LOC, status: 'open', limit: 100, page },
183
+ });
184
+ if (!r.ok) return { _err: r.code, opportunities: [] };
185
+ return r.j;
186
+ },
187
+ getItems: (resp) => resp._err ? [] : (resp.opportunities || resp.data || []),
188
+ nextCursor: (resp, items, page = 1) => {
189
+ if (resp._err || items.length < 100) return null;
190
+ return page + 1;
191
+ },
192
+ maxPages: 20,
193
+ startCursor: 1,
194
+ })) {
195
+ sum += Number(o.monetaryValue || o.monetary_value || 0) || 0;
196
+ n++;
197
+ }
198
+ return metric('Pipeline value', money(sum), { note: `${n} open deal(s)` });
199
+ }
200
+
201
+ // ── REPLY RATE ──
202
+ async function replyRate() {
203
+ const r = await ctx.http.get('/conversations/search', { query: { locationId: LOC, limit: 100 } });
204
+ if (!r.ok)
205
+ return metric('Reply rate', null, { blocked: true, blocker: `conversations HTTP ${r.code}` });
206
+ const convos = r.j.conversations || r.j.data || [];
207
+ let waiting = 0, total = 0;
208
+ for (const c of convos) {
209
+ const when = c.lastMessageDate || c.dateUpdated;
210
+ if (!inWindow(when)) continue;
211
+ total++;
212
+ if ((c.unreadCount || 0) > 0) waiting++;
213
+ }
214
+ if (total === 0)
215
+ return metric('Reply rate', null, { blocked: true, blocker: 'no conversation activity in window' });
216
+ const replied = total - waiting;
217
+ return metric('Reply rate', Math.round(replied / total * 100) + '%', { note: `${waiting} thread(s) still waiting on you` });
218
+ }
219
+
220
+ const [bk, sr] = await bookings();
221
+ const rows = await Promise.all([leads(), Promise.resolve(bk), Promise.resolve(sr), revenue(), replyRate(), pipelineValue()]);
222
+ return {
223
+ location: LOC,
224
+ window: { days: DAYS, startISO, endISO: new Date(NOW).toISOString() },
225
+ metrics: rows,
226
+ };
227
+ }
228
+
229
+ export async function run(args, ctx) {
230
+ const data = await collect(args, ctx);
231
+ ctx.out.data(data);
232
+
233
+ const DAYS = args.days != null ? args.days : (Number(args._?.[0]) || 7);
234
+ const NOW = ctx.now;
235
+ const START = NOW - DAYS * 24 * 60 * 60 * 1000;
236
+ ctx.out.card(() => {
237
+ const W = 58;
238
+ const winLabel = `${fmtManila(START)} – ${fmtManila(NOW)} (last ${DAYS}d, Manila)`;
239
+ const line = (l, v) => '│ ' + l.padEnd(16) + ' ' + String(v).padEnd(W - 20) + '│';
240
+ ctx.out.line('┌' + '─'.repeat(W - 2) + '┐');
241
+ ctx.out.line(line('SNAPSHOT', winLabel.slice(0, W - 20)));
242
+ ctx.out.line('├' + '─'.repeat(W - 2) + '┤');
243
+ for (const m of data.metrics) {
244
+ if (m.blocked) {
245
+ ctx.out.line(line(m.label, "⚠ can't see"));
246
+ ctx.out.line('│ ' + ' '.repeat(16) + ' ' + ('→ ' + m.blocker).slice(0, W - 20).padEnd(W - 20) + '│');
247
+ } else {
248
+ ctx.out.line(line(m.label, m.value));
249
+ if (m.note) ctx.out.line('│ ' + ' '.repeat(16) + ' ' + ('· ' + m.note).slice(0, W - 20).padEnd(W - 20) + '│');
250
+ }
251
+ }
252
+ ctx.out.line('└' + '─'.repeat(W - 2) + '┘');
253
+ ctx.out.line('loc ' + data.location + ' · read-only · numbers are counts, never fabricated');
254
+ });
255
+ return 0;
256
+ }
@@ -0,0 +1,142 @@
1
+ // commands/triage.mjs — Who's waiting on a reply, longest first.
2
+ // Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
3
+ // Trust-fix #2: conversations paginate to completion — --top N caps final sorted list only.
4
+ // READ-ONLY. Never sends. Agent drafts, human approves.
5
+ import { paginate } from '../lib/paginate.mjs';
6
+
7
+ export const meta = {
8
+ name: 'triage',
9
+ summary: 'Who is waiting on a reply, longest first',
10
+ flags: [
11
+ { name: '--top', type: 'int', default: 10, desc: 'max threads to show' },
12
+ { name: '--days', type: 'int', default: 30, desc: 'lookback window' },
13
+ ],
14
+ readOnly: true,
15
+ };
16
+
17
+ const CHAN = {
18
+ TYPE_SMS: 'SMS', TYPE_EMAIL: 'Email', TYPE_PHONE: 'Call', TYPE_FB: 'FB',
19
+ TYPE_IG: 'IG', TYPE_WHATSAPP: 'WhatsApp', TYPE_GMB: 'GMB',
20
+ TYPE_LIVE_CHAT: 'Chat', TYPE_NO_SHOW: '(no-show)',
21
+ };
22
+
23
+ export async function collect(args, ctx) {
24
+ const TOP = args.top ?? 10;
25
+ const DAYS = args.days ?? 30;
26
+ const LOC = ctx.cfg.loc;
27
+ const NOW = ctx.now;
28
+ const START = NOW - DAYS * 24 * 60 * 60 * 1000;
29
+ const ago = (ms) => {
30
+ const d = Math.floor((NOW - ms) / 86400000);
31
+ if (d >= 1) return d + 'd';
32
+ const h = Math.floor((NOW - ms) / 3600000);
33
+ if (h >= 1) return h + 'h';
34
+ return Math.max(1, Math.floor((NOW - ms) / 60000)) + 'm';
35
+ };
36
+
37
+ // paginate conversations to completion — limit:100 per page, offset-based (trust-fix #2)
38
+ const convos = [];
39
+ let convErr = null;
40
+ for await (const c of paginate({
41
+ fetchPage: async (offset = 0) => {
42
+ const r = await ctx.http.get('/conversations/search', {
43
+ query: { locationId: LOC, limit: 100, offset },
44
+ version: '2021-04-15',
45
+ });
46
+ if (!r.ok) return { _err: r.code, conversations: [] };
47
+ return r.j;
48
+ },
49
+ getItems: (resp) => {
50
+ if (resp._err) { convErr = resp._err; return []; }
51
+ return resp.conversations || resp.data || [];
52
+ },
53
+ nextCursor: (resp, items, offset = 0) => {
54
+ if (resp._err || items.length < 100) return null;
55
+ return offset + 100;
56
+ },
57
+ maxPages: 200,
58
+ startCursor: 0,
59
+ })) {
60
+ convos.push(c);
61
+ }
62
+
63
+ if (convErr && convos.length === 0) {
64
+ ctx.out.warn(`can't see conversations → HTTP ${convErr}`, { degraded: true });
65
+ return { location: LOC, scanned: 0, waiting: 0, shown: 0, threads: [] };
66
+ }
67
+
68
+ const waiting = convos
69
+ .filter(c => (c.unreadCount || 0) > 0 && (c.lastMessageDate || 0) >= START)
70
+ .sort((a, b) => (a.lastMessageDate || 0) - (b.lastMessageDate || 0));
71
+ const top = waiting.slice(0, TOP);
72
+
73
+ // fetch last inbound snippet for context (top-N only, cheap)
74
+ async function lastInbound(convId) {
75
+ const r = await ctx.http.get(`/conversations/${convId}/messages`, {
76
+ query: { limit: 20 },
77
+ version: '2021-04-15',
78
+ });
79
+ if (!r.ok) return '';
80
+ const msgs = r.j.messages?.messages || r.j.messages || r.j.data || [];
81
+ const inb = msgs.find(m => (m.direction || '').toLowerCase() === 'inbound');
82
+ const b = (inb?.body || inb?.message || '').replace(/\s+/g, ' ').trim();
83
+ return b.slice(0, 90);
84
+ }
85
+
86
+ for (const c of top) { c._snippet = await lastInbound(c.id); }
87
+
88
+ return {
89
+ location: LOC,
90
+ scanned: convos.length,
91
+ waiting: waiting.length,
92
+ shown: top.length,
93
+ threads: top.map(c => ({
94
+ rank: 0,
95
+ name: c.contactName || c.fullName,
96
+ channel: CHAN[c.lastMessageType] || c.type,
97
+ waiting: ago(c.lastMessageDate),
98
+ unread: c.unreadCount,
99
+ snippet: c._snippet,
100
+ conversationId: c.id,
101
+ contactId: c.contactId,
102
+ email: c.email,
103
+ })),
104
+ };
105
+ }
106
+
107
+ export async function run(args, ctx) {
108
+ const data = await collect(args, ctx);
109
+ ctx.out.data(data);
110
+
111
+ const TOP = args.top ?? 10;
112
+ const DAYS = args.days ?? 30;
113
+ const LOC = ctx.cfg.loc;
114
+ const NOW = ctx.now;
115
+ const ago = (ms) => {
116
+ const d = Math.floor((NOW - ms) / 86400000);
117
+ if (d >= 1) return d + 'd';
118
+ const h = Math.floor((NOW - ms) / 3600000);
119
+ if (h >= 1) return h + 'h';
120
+ return Math.max(1, Math.floor((NOW - ms) / 60000)) + 'm';
121
+ };
122
+
123
+ ctx.out.card(() => {
124
+ ctx.out.line(`\n TRIAGE — ${data.waiting} thread(s) waiting on you · showing top ${data.shown} · last ${DAYS}d · loc ${LOC}`);
125
+ ctx.out.line(' ' + '─'.repeat(72));
126
+ if (!data.threads.length) {
127
+ ctx.out.line(' Inbox clear — nobody waiting on a reply. ✅\n');
128
+ return;
129
+ }
130
+ // reconstruct display from convos in data — thread ordering preserved
131
+ data.threads.forEach((t, i) => {
132
+ const name = (t.name || '(no name)').slice(0, 22);
133
+ const chan = (t.channel || '?').replace('TYPE_', '');
134
+ ctx.out.line(` ${String(i + 1).padStart(2)}. ${name.padEnd(22)} ${chan.padEnd(7)} waiting ${(t.waiting || '?').padEnd(4)} · ${t.unread} unread`);
135
+ if (t.snippet) ctx.out.line(` "${t.snippet}"`);
136
+ ctx.out.line(` conv ${t.conversationId} · contact ${t.contactId}`);
137
+ });
138
+ ctx.out.line(' ' + '─'.repeat(72));
139
+ ctx.out.line(' → I draft a reply per thread; you approve each before it sends (L2, human-gated).\n');
140
+ });
141
+ return 0;
142
+ }
@@ -0,0 +1,54 @@
1
+ # booked-not-paid — the money leak
2
+
3
+ ## What it answers
4
+
5
+ "Who had a session but was never invoiced or never paid?" Cross-references calendar events (attended appointments) against invoices and payment transactions. Surfaces two buckets: contacts who were never invoiced, and contacts who were invoiced but never paid.
6
+
7
+ ## Command
8
+
9
+ ```sh
10
+ sizmo booked-not-paid
11
+ sizmo booked-not-paid --days 14 # last 14 days
12
+ sizmo booked-not-paid --top 20 # show up to 20 per bucket
13
+ sizmo booked-not-paid --json
14
+ sizmo booked-not-paid --profile myclient
15
+ ```
16
+
17
+ Flags (verified from `meta` in `commands/booked-not-paid.mjs`):
18
+
19
+ | Flag | Type | Default | Description |
20
+ |------|------|---------|-------------|
21
+ | `--days` | int | 30 | Session lookback window |
22
+ | `--top` | int | 15 | Max rows to show per bucket |
23
+
24
+ ## How it works
25
+
26
+ 1. Fetches all attended calendar events within the window (all calendars)
27
+ 2. Fetches all invoices for contacts who had sessions
28
+ 3. Paginates all payment transactions to completion (critical: the old single-page limit:100 missed paid contacts — this is fixed)
29
+ 4. A contact is `neverBilled` if they have an attended session and no invoice at all
30
+ 5. A contact is in `invoicedNotPaid` if they have an invoice in an unpaid status and no successful transaction
31
+
32
+ The transaction pagination is exhaustive to avoid false positives — a contact is only flagged as unpaid if ALL pages of their transactions confirm no successful payment.
33
+
34
+ **Known limitation:** Same calendar truncation issue as `noshow` — calendars returning >= 100 events may be silently truncated. `degraded: true` warning is emitted.
35
+
36
+ ## Sample output shape (example — no live creds in this context)
37
+
38
+ ```
39
+ NEVER BILLED (had session, no invoice)
40
+ 1. Ana Lim session 5d ago
41
+ 2. Bong Santos session 8d ago
42
+
43
+ INVOICED NOT PAID
44
+ 1. Juan dela Cruz ₱25,000 invoice sent 21d ago
45
+ 2. Maria Santos ₱12,000 invoice viewed 9d ago
46
+ ```
47
+
48
+ *Sample shape only.*
49
+
50
+ ## Notes
51
+
52
+ - The CLI never creates an invoice or charges a card. Use this list to identify the gap; action stays with you.
53
+ - `neverBilled` contacts show `estValue: 0` in JSON — the value is truly unknown until you decide what to charge.
54
+ - `--top N` caps each bucket independently. All events and transactions are fetched before the cap is applied.
@@ -0,0 +1,71 @@
1
+ # brief — morning brief
2
+
3
+ ## What it answers
4
+
5
+ "What needs my attention today?" Combines snapshot numbers, triage threads, no-shows, stuck deals, and unpaid invoices into a single ranked morning card. The NEEDS YOU TODAY section is ordered by money at stake — highest-value items first, unknown-value items below.
6
+
7
+ Start here every morning before opening GoHighLevel.
8
+
9
+ ## Command
10
+
11
+ ```sh
12
+ sizmo brief
13
+ sizmo brief --days 14 # widen the snapshot window to 14 days
14
+ sizmo brief --json # machine-readable envelope
15
+ sizmo brief --profile myclient # target a specific credential profile
16
+ ```
17
+
18
+ Flags (verified from `meta` in `commands/brief.mjs`):
19
+
20
+ | Flag | Type | Default | Description |
21
+ |------|------|---------|-------------|
22
+ | `--days` | int | 7 | Snapshot window in days |
23
+
24
+ Global flags `--json`, `--profile`, `--fresh` also apply.
25
+
26
+ ## How it works
27
+
28
+ `brief` fans out five sub-collects in parallel on the same HTTP client and rate-limit pool:
29
+
30
+ 1. `snapshot` — 6 headline metrics
31
+ 2. `triage` — unanswered conversation threads (lookback: 30d, cap: 100)
32
+ 3. `noshow` — no-shows to re-book (lookback: 30d, cap: 100)
33
+ 4. `pipeline` — stuck deals (threshold: 7d, cap: 100)
34
+ 5. `receivables` — outstanding invoices (cap: 100)
35
+
36
+ Each sub-collect is wrapped in a fault-tolerant `safe()` — if one source is blocked (e.g. missing scope), it emits `degraded: true` in the envelope and shows a warning instead of crashing the brief.
37
+
38
+ NEEDS YOU TODAY is ranked by `rankActions` from `lib/prioritize.mjs` — the same ranker used by `focus`. Money-valued items (stuck deals, invoices) come first in descending dollar order; items with unknown value (waiting threads, no-shows) follow.
39
+
40
+ ## Sample output shape (example — no live creds in this context)
41
+
42
+ ```
43
+ ╔════════════════════════════════════════════════════════════════╗
44
+ ║ MORNING BRIEF — Monday, Jun 9 ║
45
+ ║ loc LOC_XXXX · read-only ║
46
+ ╚════════════════════════════════════════════════════════════════╝
47
+
48
+ THE NUMBERS (last 7d)
49
+ ────────────────────────────────────────────────────────────────
50
+ New leads 12
51
+ Bookings 8
52
+ Show rate 75%
53
+ Collected ₱48,000
54
+ Reply rate 83%
55
+ Pipeline ₱320,000
56
+
57
+ NEEDS YOU TODAY
58
+ ────────────────────────────────────────────────────────────────
59
+ 1. Juan dela Cruz — ₱25,000 invoice (21d overdue) → sizmo receivables
60
+ 2. Maria Santos — stuck deal ₱18,000 (12d idle) → sizmo pipeline
61
+ 3. (no-show) Carlo Reyes — 3d ago → sizmo noshow
62
+ ...
63
+ ```
64
+
65
+ *Sample shape only. Actual numbers depend on your GoHighLevel location data.*
66
+
67
+ ## Notes
68
+
69
+ - `degraded: true` in the JSON envelope means at least one source was blocked. Check `warnings` for detail. Never treat a blocked source as zero.
70
+ - The `--days` flag affects only the snapshot window. Triage, noshow, pipeline, and receivables sub-collects use their own defaults (30d lookback for triage/noshow, 7d stuck threshold for pipeline).
71
+ - To drill into any item, run its recipe: `sizmo receivables`, `sizmo pipeline`, `sizmo triage`, etc.
@@ -0,0 +1,73 @@
1
+ # Configure a client profile
2
+
3
+ A profile stores a PIT (Private Integration Token) and Location ID under a name. Use one profile per GoHighLevel location.
4
+
5
+ ## Step 1 — get your PIT
6
+
7
+ In GoHighLevel: Settings → Integrations → Private Integrations → Create. Grant read-only scopes. Copy the token (starts with `pit-`).
8
+
9
+ ## Step 2 — find your Location ID
10
+
11
+ In GoHighLevel: Settings → Business Profile. The Location ID is in the URL or displayed in the page. It is a long alphanumeric string.
12
+
13
+ ## Step 3 — save the profile
14
+
15
+ ```sh
16
+ echo "pit-yourtoken..." | sizmo config set \
17
+ --profile myclient \
18
+ --loc YOUR_LOCATION_ID \
19
+ --pit-stdin
20
+ ```
21
+
22
+ - `--pit-stdin` reads the PIT from stdin — it never touches command-line history or shell logs
23
+ - The `--` is optional; any order of flags works
24
+ - A `--created` date is set automatically to today (used to track PIT age)
25
+
26
+ You can also add an optional label:
27
+
28
+ ```sh
29
+ echo "pit-yourtoken..." | sizmo config set \
30
+ --profile myclient \
31
+ --loc YOUR_LOCATION_ID \
32
+ --label "Coaching Client - ABC" \
33
+ --pit-stdin
34
+ ```
35
+
36
+ ## Step 4 — verify
37
+
38
+ ```sh
39
+ sizmo auth status --profile myclient
40
+ sizmo auth check --profile myclient
41
+ ```
42
+
43
+ `auth status` shows the saved data (source, location, masked PIT, age). `auth check` makes a live API call to confirm the PIT is accepted.
44
+
45
+ ## Update a profile
46
+
47
+ Re-run `config set` with the same `--profile` name. Fields you specify overwrite; fields you omit are preserved.
48
+
49
+ ```sh
50
+ # Update location ID only
51
+ sizmo config set --profile myclient --loc NEW_LOC_ID
52
+
53
+ # Rotate PIT only
54
+ echo "pit-newtoken..." | sizmo config set --profile myclient --pit-stdin --created $(date +%Y-%m-%d)
55
+ ```
56
+
57
+ ## Remove a profile
58
+
59
+ ```sh
60
+ sizmo config rm myclient
61
+ ```
62
+
63
+ ## Using environment variables instead
64
+
65
+ If you prefer not to use profiles:
66
+
67
+ ```sh
68
+ export GHL_PIT=pit-yourtoken...
69
+ export GHL_LOCATION_ID=YOUR_LOCATION_ID
70
+ sizmo brief
71
+ ```
72
+
73
+ Environment variables take precedence over saved profiles. Neither variable is written to disk by sizmo.
@@ -0,0 +1,46 @@
1
+ # focus — ranked to-do queue
2
+
3
+ ## What it answers
4
+
5
+ "What's the single most important thing I should do right now, ordered by money?" Returns one unified list across all lanes — stuck deals, overdue invoices, never-billed sessions, unanswered threads, and no-shows — ranked by dollar value descending.
6
+
7
+ Use `focus` when you want the ranked queue without the brief's full morning card layout.
8
+
9
+ ## Command
10
+
11
+ ```sh
12
+ sizmo focus
13
+ sizmo focus --top 10 # show top 10 items
14
+ sizmo focus --stuck-days 14 # widen the stuck-deal threshold
15
+ sizmo focus --json
16
+ sizmo focus --profile myclient
17
+ ```
18
+
19
+ Flags (verified from `meta` in `commands/focus.mjs`):
20
+
21
+ | Flag | Type | Default | Description |
22
+ |------|------|---------|-------------|
23
+ | `--top` | int | 15 | Max items to display |
24
+ | `--stuck-days` | int | 7 | Idle threshold for stuck deals |
25
+
26
+ ## How it works
27
+
28
+ Same five sub-collects as `brief`, same `rankActions` ranker from `lib/prioritize.mjs`. Output is a flat numbered list — no card headers, no sections. Designed to feed into automation or agent pipelines via `--json`.
29
+
30
+ ## Sample output shape (example — no live creds in this context)
31
+
32
+ ```
33
+ 1. Juan dela Cruz — invoice ₱25,000 (21d) → sizmo receivables
34
+ 2. Maria Santos — stuck deal ₱18,000 (12d) → sizmo pipeline
35
+ 3. (never billed) Carlo Reyes — session 5d ago → sizmo booked-not-paid
36
+ 4. Ana Reyes — waiting reply (8d) → sizmo triage
37
+ ...
38
+ ```
39
+
40
+ *Sample shape only.*
41
+
42
+ ## Notes
43
+
44
+ - Items with known monetary value (deals, invoices) rank above items with unknown value (threads, no-shows).
45
+ - `--stuck-days` affects how pipeline defines a "stuck" deal. A deal is stuck when it has had no stage change or status update in N days.
46
+ - Use `sizmo brief` if you want the morning card format with headline metrics. Use `focus` for a clean list.