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.
- package/INSTALL.md +127 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/SKILL.md +31 -0
- package/bin/sizmo.mjs +3 -0
- package/commands/booked-not-paid.mjs +242 -0
- package/commands/brief.mjs +213 -0
- package/commands/focus.mjs +163 -0
- package/commands/noshow.mjs +117 -0
- package/commands/pipeline.mjs +147 -0
- package/commands/receivables.mjs +119 -0
- package/commands/reconcile.mjs +205 -0
- package/commands/segment.mjs +133 -0
- package/commands/snapshot.mjs +256 -0
- package/commands/triage.mjs +142 -0
- package/docs/how-to/booked-not-paid.md +54 -0
- package/docs/how-to/brief.md +71 -0
- package/docs/how-to/configure-a-client-profile.md +73 -0
- package/docs/how-to/focus.md +46 -0
- package/docs/how-to/multi-client.md +86 -0
- package/docs/how-to/noshow.md +45 -0
- package/docs/how-to/pipeline.md +47 -0
- package/docs/how-to/receivables.md +45 -0
- package/docs/how-to/reconcile.md +52 -0
- package/docs/how-to/segment.md +55 -0
- package/docs/how-to/snapshot.md +52 -0
- package/docs/how-to/triage.md +44 -0
- package/lib/cache.mjs +36 -0
- package/lib/cli.mjs +311 -0
- package/lib/config.mjs +39 -0
- package/lib/context.mjs +33 -0
- package/lib/errors.mjs +11 -0
- package/lib/http.mjs +52 -0
- package/lib/output.mjs +39 -0
- package/lib/paginate.mjs +13 -0
- package/lib/pool.mjs +10 -0
- package/lib/prioritize.mjs +146 -0
- package/lib/registry.mjs +13 -0
- package/lib/schema.mjs +9 -0
- package/package.json +45 -0
|
@@ -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.
|