clearhand 0.1.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/LICENSE +202 -0
- package/README.md +146 -0
- package/SECURITY.md +77 -0
- package/bin/clearhand.js +230 -0
- package/dist/mcp/index.js +159 -0
- package/dist/server/connectors/crawl.js +52 -0
- package/dist/server/connectors/github.js +45 -0
- package/dist/server/connectors/mailbox.js +257 -0
- package/dist/server/connectors/stripe.js +252 -0
- package/dist/server/context.js +83 -0
- package/dist/server/db/index.js +324 -0
- package/dist/server/demo.js +194 -0
- package/dist/server/index.js +51 -0
- package/dist/server/routes/api.js +704 -0
- package/dist/server/secrets/redaction.js +62 -0
- package/dist/server/secrets/store.js +238 -0
- package/dist/server/services/approval.js +206 -0
- package/dist/server/services/connections.js +99 -0
- package/dist/server/services/executor.js +417 -0
- package/dist/server/services/lint.js +129 -0
- package/dist/server/services/onboarding.js +168 -0
- package/dist/server/services/overlay.js +65 -0
- package/dist/server/trust/egress.js +12 -0
- package/dist/shared/actions.js +136 -0
- package/dist/shared/config.js +33 -0
- package/dist/shared/paths.js +34 -0
- package/package.json +73 -0
- package/public/assets/index-BicJ8AKo.css +1 -0
- package/public/assets/index-DZSNZW8-.js +43 -0
- package/public/index.html +17 -0
- package/templates/CLEARHAND.md +79 -0
- package/templates/overlay/escalation.md +21 -0
- package/templates/overlay/policies.md +30 -0
- package/templates/overlay/products.md +25 -0
- package/templates/overlay/tone-lint.json +14 -0
- package/templates/overlay/voice.md +24 -0
- package/templates/skills/clearhand-cs/SKILL.md +75 -0
- package/templates/skills/clearhand-onboard/SKILL.md +126 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { now } from './db/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Demo mode: seeded sample cases + simulated connectors so anyone can run
|
|
4
|
+
* `clearhand demo`, click through the full propose → approve → execute loop,
|
|
5
|
+
* and watch every guard fire — without connecting a real Stripe account or
|
|
6
|
+
* mailbox. No external host is contacted in demo mode.
|
|
7
|
+
*/
|
|
8
|
+
export function makeDemoExecutorOps() {
|
|
9
|
+
// Simulated Stripe state, keyed by id. sub_demo2 is already scheduled to
|
|
10
|
+
// cancel — approving another end_of_period cancel demonstrates the
|
|
11
|
+
// proration guard's idempotent no-op.
|
|
12
|
+
const subs = new Map([
|
|
13
|
+
['sub_demoAAAAAAAAAAAA', { id: 'sub_demoAAAAAAAAAAAA', status: 'active', cancel_at_period_end: false, cancel_at: null }],
|
|
14
|
+
['sub_demoBBBBBBBBBBBB', { id: 'sub_demoBBBBBBBBBBBB', status: 'active', cancel_at_period_end: true, cancel_at: null }],
|
|
15
|
+
]);
|
|
16
|
+
const charges = new Map([
|
|
17
|
+
['ch_demoAAAAAAAAAAAAA', { id: 'ch_demoAAAAAAAAAAAAA', amount: 2900, currency: 'usd', refunded: false, amount_refunded: 0 }],
|
|
18
|
+
['ch_demoBBBBBBBBBBBBB', { id: 'ch_demoBBBBBBBBBBBBB', amount: 9900, currency: 'usd', refunded: false, amount_refunded: 0 }],
|
|
19
|
+
]);
|
|
20
|
+
let refundSeq = 0;
|
|
21
|
+
let issueSeq = 41;
|
|
22
|
+
return {
|
|
23
|
+
stripe: {
|
|
24
|
+
async retrieveSubscription(subId) {
|
|
25
|
+
const s = subs.get(subId);
|
|
26
|
+
if (!s)
|
|
27
|
+
throw new Error(`resource_missing: No such subscription: ${subId}`);
|
|
28
|
+
return { ...s };
|
|
29
|
+
},
|
|
30
|
+
async cancelNow(subId) {
|
|
31
|
+
const s = subs.get(subId);
|
|
32
|
+
if (!s)
|
|
33
|
+
throw new Error(`resource_missing: No such subscription: ${subId}`);
|
|
34
|
+
s.status = 'canceled';
|
|
35
|
+
return { ...s };
|
|
36
|
+
},
|
|
37
|
+
async cancelAtPeriodEnd(subId) {
|
|
38
|
+
const s = subs.get(subId);
|
|
39
|
+
if (!s)
|
|
40
|
+
throw new Error(`resource_missing: No such subscription: ${subId}`);
|
|
41
|
+
s.cancel_at_period_end = true;
|
|
42
|
+
return { ...s };
|
|
43
|
+
},
|
|
44
|
+
async cancelAt(subId, epochSeconds) {
|
|
45
|
+
const s = subs.get(subId);
|
|
46
|
+
if (!s)
|
|
47
|
+
throw new Error(`resource_missing: No such subscription: ${subId}`);
|
|
48
|
+
s.cancel_at = epochSeconds;
|
|
49
|
+
return { ...s };
|
|
50
|
+
},
|
|
51
|
+
async retrieveCharge(chargeId) {
|
|
52
|
+
const c = charges.get(chargeId);
|
|
53
|
+
if (!c)
|
|
54
|
+
throw new Error(`resource_missing: No such charge: ${chargeId}`);
|
|
55
|
+
return { ...c };
|
|
56
|
+
},
|
|
57
|
+
async createRefund(params) {
|
|
58
|
+
const c = charges.get(params.chargeId);
|
|
59
|
+
if (!c)
|
|
60
|
+
throw new Error(`resource_missing: No such charge: ${params.chargeId}`);
|
|
61
|
+
if (c.refunded)
|
|
62
|
+
throw new Error('charge_already_refunded: This charge has already been refunded.');
|
|
63
|
+
const amount = params.amountCents ?? c.amount;
|
|
64
|
+
c.amount_refunded += amount;
|
|
65
|
+
c.refunded = c.amount_refunded >= c.amount;
|
|
66
|
+
return { id: `re_demo${++refundSeq}`, status: 'succeeded', amount };
|
|
67
|
+
},
|
|
68
|
+
async latestPaidInvoiceCharge(subId) {
|
|
69
|
+
return subId === 'sub_demoAAAAAAAAAAAA' ? 'ch_demoAAAAAAAAAAAAA' : null;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
mailer: {
|
|
73
|
+
async sendReply(params) {
|
|
74
|
+
return { providerMessageId: `<demo-${Date.now()}@clearhand.local>` };
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
github: {
|
|
78
|
+
async createIssue(params) {
|
|
79
|
+
return { number: ++issueSeq, url: `https://github.com/demo/app/issues/${issueSeq}` };
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function seedDemoData(db) {
|
|
85
|
+
if (db.prepare(`SELECT COUNT(*) AS n FROM cases`).get().n > 0)
|
|
86
|
+
return;
|
|
87
|
+
const t = now();
|
|
88
|
+
const customer = (email, name) => Number(db
|
|
89
|
+
.prepare(`INSERT INTO customers (email, name, created_at, updated_at) VALUES (?, ?, ?, ?)`)
|
|
90
|
+
.run(email, name, t, t).lastInsertRowid);
|
|
91
|
+
const mkCase = (customerId, subject, category, summary, ctx) => Number(db
|
|
92
|
+
.prepare(`INSERT INTO cases (customer_id, source, conversation_key, subject, status, category, summary,
|
|
93
|
+
prepared_context_json, enriched_at, created_at, updated_at)
|
|
94
|
+
VALUES (?, 'demo', ?, ?, 'processed', ?, ?, ?, ?, ?, ?)`)
|
|
95
|
+
.run(customerId, `demo-${subject}`, subject, category, summary, JSON.stringify(ctx), t, t, t).lastInsertRowid);
|
|
96
|
+
const email = (caseId, from, subject, body) => db
|
|
97
|
+
.prepare(`INSERT INTO emails (case_id, direction, from_addr, to_addr, subject, body_text, provider_message_id, received_at, created_at)
|
|
98
|
+
VALUES (?, 'inbound', ?, 'support@demo.app', ?, ?, ?, ?, ?)`)
|
|
99
|
+
.run(caseId, from, subject, body, `<demo-${caseId}-${Math.abs(subject.length)}@mail.example>`, t, t);
|
|
100
|
+
const draft = (caseId, body) => db
|
|
101
|
+
.prepare(`INSERT INTO drafts (case_id, version, body_english, is_current, created_by, created_at)
|
|
102
|
+
VALUES (?, 1, ?, 1, 'agent', ?)`)
|
|
103
|
+
.run(caseId, body, t);
|
|
104
|
+
const action = (caseId, type, proposal, order, confidence) => db
|
|
105
|
+
.prepare(`INSERT INTO actions (case_id, action_type, status, execution_order, proposal_json, confidence, created_at)
|
|
106
|
+
VALUES (?, ?, 'proposed', ?, ?, ?, ?)`)
|
|
107
|
+
.run(caseId, type, order, JSON.stringify(proposal), confidence, t);
|
|
108
|
+
// Case 1 — refund request, full loop: refund + reply + resolve.
|
|
109
|
+
const c1cust = customer('mara@example.com', 'Mara Jensen');
|
|
110
|
+
const stripeCtx1 = {
|
|
111
|
+
stripe: {
|
|
112
|
+
found: true,
|
|
113
|
+
fetchedAt: t,
|
|
114
|
+
customers: [
|
|
115
|
+
{
|
|
116
|
+
customerId: 'cus_demoAAAAAAAAAAAA',
|
|
117
|
+
created: t,
|
|
118
|
+
currency: 'usd',
|
|
119
|
+
ltvCents: 8700,
|
|
120
|
+
subscriptions: [
|
|
121
|
+
{
|
|
122
|
+
id: 'sub_demoAAAAAAAAAAAA', status: 'active', priceNickname: 'Pro Monthly',
|
|
123
|
+
priceId: 'price_demoA', amountCents: 2900, interval: 'month',
|
|
124
|
+
currentPeriodEnd: new Date(Date.now() + 12 * 86_400_000).toISOString(),
|
|
125
|
+
cancelAtPeriodEnd: false,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
recentCharges: [
|
|
129
|
+
{ id: 'ch_demoAAAAAAAAAAAAA', amountCents: 2900, currency: 'usd', created: t, status: 'succeeded', refunded: false, amountRefundedCents: 0 },
|
|
130
|
+
],
|
|
131
|
+
refunds: { count: 0, totalCents: 0 },
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const c1 = mkCase(c1cust, 'Please refund my last charge', 'refund', 'Customer was charged after intending to cancel. First refund request; LTV $87; within policy window.', stripeCtx1);
|
|
137
|
+
email(c1, 'mara@example.com', 'Please refund my last charge', 'Hi, I meant to cancel before renewal but got charged $29 yesterday. Could you refund it and cancel my subscription? Thanks, Mara');
|
|
138
|
+
draft(c1, 'Hi Mara,\n\nThanks for reaching out. I\'ve refunded the $29.00 charge from yesterday — it should appear on your statement within 5–10 business days. Your subscription is set to cancel at the end of the current period, so you\'ll keep access until then and won\'t be billed again.\n\nDemo Support');
|
|
139
|
+
action(c1, 'refund', { charge_id: 'ch_demoAAAAAAAAAAAAA', amount_cents: 2900, currency: 'usd', reason: 'Charged after intended cancellation; first refund, within window.' }, 0, 0.93);
|
|
140
|
+
action(c1, 'cancel_subscription', { subscription_id: 'sub_demoAAAAAAAAAAAA', cancel_mode: 'end_of_period', customer_id: 'cus_demoAAAAAAAAAAAA', reason: 'Customer asked to cancel.' }, 5, 0.95);
|
|
141
|
+
action(c1, 'send_reply', { body_text: 'Hi Mara,\n\nThanks for reaching out. I\'ve refunded the $29.00 charge from yesterday — it should appear on your statement within 5–10 business days. Your subscription is set to cancel at the end of the current period.\n\nDemo Support', message: 'Confirm refund + end-of-period cancel' }, 10, 0.9);
|
|
142
|
+
action(c1, 'set_case_status', { target_status: 'resolved', reason: 'Refund + cancel + confirmation cover the request.' }, 99, 0.9);
|
|
143
|
+
// Case 2 — proration-guard showcase: sub already scheduled to cancel.
|
|
144
|
+
const c2cust = customer('theo@example.io', 'Theo Brandt');
|
|
145
|
+
const c2 = mkCase(c2cust, 'Did my cancellation go through?', 'subscription', 'Customer cancelled last week (already scheduled at period end) but got a reminder email and is worried.', {
|
|
146
|
+
stripe: {
|
|
147
|
+
found: true, fetchedAt: t,
|
|
148
|
+
customers: [{
|
|
149
|
+
customerId: 'cus_demoBBBBBBBBBBBB', created: t, currency: 'usd', ltvCents: 29700,
|
|
150
|
+
subscriptions: [{ id: 'sub_demoBBBBBBBBBBBB', status: 'active', priceNickname: 'Team Annual', priceId: 'price_demoB', amountCents: 9900, interval: 'year', currentPeriodEnd: new Date(Date.now() + 40 * 86_400_000).toISOString(), cancelAtPeriodEnd: true }],
|
|
151
|
+
recentCharges: [{ id: 'ch_demoBBBBBBBBBBBBB', amountCents: 9900, currency: 'usd', created: t, status: 'succeeded', refunded: false, amountRefundedCents: 0 }],
|
|
152
|
+
refunds: { count: 0, totalCents: 0 },
|
|
153
|
+
}],
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
email(c2, 'theo@example.io', 'Did my cancellation go through?', 'I cancelled my plan last week but just got a "your renewal is coming" email. Is my cancellation actually scheduled? I do NOT want to be charged again.');
|
|
157
|
+
draft(c2, 'Hi Theo,\n\nThanks for reaching out — I checked, and your cancellation is scheduled correctly: your Team Annual plan will end at the close of the current period and you will not be charged again. The renewal reminder was sent automatically before the cancellation took effect; you can safely ignore it.\n\nDemo Support');
|
|
158
|
+
// Approving this cancel demonstrates the proration guard: already scheduled
|
|
159
|
+
// → idempotent no-op, nothing re-applied.
|
|
160
|
+
action(c2, 'cancel_subscription', { subscription_id: 'sub_demoBBBBBBBBBBBB', cancel_mode: 'end_of_period', customer_id: 'cus_demoBBBBBBBBBBBB', reason: 'Reassure: verify schedule (already scheduled — guard will no-op).' }, 5, 0.88);
|
|
161
|
+
action(c2, 'send_reply', { body_text: 'Hi Theo,\n\nThanks for reaching out — I checked, and your cancellation is scheduled correctly: your plan ends at the close of the current period and you will not be charged again.\n\nDemo Support', message: 'Reassure cancellation is scheduled' }, 10, 0.92);
|
|
162
|
+
action(c2, 'set_case_status', { target_status: 'resolved', reason: 'Verified schedule + reassured customer.' }, 99, 0.9);
|
|
163
|
+
// Case 3 — bug report → GitHub issue + reply.
|
|
164
|
+
const c3cust = customer('lin@example.dev', 'Lin Okafor');
|
|
165
|
+
const c3 = mkCase(c3cust, 'Export button broken on Safari', 'bug', 'Reproducible export failure on Safari 17; no billing impact; needs an issue + acknowledgement.', { notes: 'Repro confirmed from screenshot: TypeError in export.js on Safari 17.4.' });
|
|
166
|
+
email(c3, 'lin@example.dev', 'Export button broken on Safari', 'The CSV export button does nothing on Safari 17.4 (works in Chrome). Console shows "TypeError: undefined is not a function". Screenshot attached.');
|
|
167
|
+
draft(c3, 'Hi Lin,\n\nThanks for the detailed report — the console error and Safari version made this easy to track. I\'ve filed it with engineering and we\'ll follow up as soon as a fix ships. In the meantime, export works in Chrome and Firefox.\n\nDemo Support');
|
|
168
|
+
action(c3, 'create_github_issue', { title: 'CSV export button no-ops on Safari 17.4 (TypeError in export.js)', body: 'Reported by a customer (case #3).\n\nRepro: Safari 17.4 → dashboard → Export CSV → nothing happens.\nConsole: `TypeError: undefined is not a function` in export.js.\nWorks in Chrome/Firefox.\n\nCustomer impact: blocked exports for Safari users.', labels: ['bug', 'from-support'] }, 50, 0.9);
|
|
169
|
+
action(c3, 'send_reply', { body_text: 'Hi Lin,\n\nThanks for the detailed report — the console error and Safari version made this easy to track. I\'ve filed it with engineering and we\'ll follow up as soon as a fix ships. In the meantime, export works in Chrome and Firefox.\n\nDemo Support', message: 'Acknowledge bug + filed issue' }, 10, 0.9);
|
|
170
|
+
action(c3, 'set_case_status', { target_status: 'pending', reason: 'Waiting on engineering fix before final confirmation.' }, 99, 0.85);
|
|
171
|
+
// Case 4 — resolve-guard showcase: a resolve proposed while a refund is
|
|
172
|
+
// still pending. Approving ONLY the status action trips the guard.
|
|
173
|
+
const c4cust = customer('sofia@example.org', 'Sofia Reyes');
|
|
174
|
+
const c4 = mkCase(c4cust, 'Double charged this month', 'billing', 'Two charges of $99 this month; one is a duplicate and owed back. GUARD DEMO: try approving only the "resolve" action.', {
|
|
175
|
+
stripe: {
|
|
176
|
+
found: true, fetchedAt: t,
|
|
177
|
+
customers: [{
|
|
178
|
+
customerId: 'cus_demoCCCCCCCCCCCC', created: t, currency: 'usd', ltvCents: 19800,
|
|
179
|
+
subscriptions: [],
|
|
180
|
+
recentCharges: [{ id: 'ch_demoBBBBBBBBBBBBB', amountCents: 9900, currency: 'usd', created: t, status: 'succeeded', refunded: false, amountRefundedCents: 0 }],
|
|
181
|
+
refunds: { count: 0, totalCents: 0 },
|
|
182
|
+
}],
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
email(c4, 'sofia@example.org', 'Double charged this month', 'You charged me twice this month — $99 on the 1st and again on the 3rd. Please refund the duplicate.');
|
|
186
|
+
draft(c4, 'Hi Sofia,\n\nYou\'re right, and I\'m sorry about that — the second $99 charge was a duplicate. I\'ve refunded it in full; it should reach your account within 5–10 business days.\n\nDemo Support');
|
|
187
|
+
action(c4, 'refund', { charge_id: 'ch_demoBBBBBBBBBBBBB', amount_cents: 9900, currency: 'usd', reason: 'Duplicate charge — full refund owed.' }, 0, 0.97);
|
|
188
|
+
action(c4, 'set_case_status', { target_status: 'resolved', reason: 'Duplicate refunded.' }, 99, 0.9);
|
|
189
|
+
// A couple of learning-loop artifacts so the dashboard isn't empty.
|
|
190
|
+
db.prepare(`INSERT INTO feedback_events (case_id, event_type, original_json, modified_json, diff_summary, created_at)
|
|
191
|
+
VALUES (?, 'draft_edit', ?, ?, ?, ?)`).run(c1, JSON.stringify({ body: 'Dear Mara, Thank you for contacting us...' }), JSON.stringify({ body: 'Hi Mara, Thanks for reaching out...' }), 'greeting: "Dear" → "Hi"; opener: "Thank you for contacting us" → "Thanks for reaching out"', t);
|
|
192
|
+
db.prepare(`INSERT INTO calibrations (customer_message, context_note, clearhand_draft, actual_reply, created_at)
|
|
193
|
+
VALUES (?, ?, ?, ?, ?)`).run('Do you offer a student discount? I\'m doing my thesis and $29/mo is steep.', 'Pricing question; no discount policy in overlay yet.', 'Hi — thanks for asking! We don\'t have a formal student discount today, but if you write in from your university address I can apply 30% off for 6 months while you finish your thesis.\n\nDemo Support', 'Hey! We don\'t have student pricing officially but I\'ll give you 50% for the semester — send me your uni email. Good luck with the thesis!', t);
|
|
194
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createAppContext } from './context.js';
|
|
6
|
+
import { apiRouter } from './routes/api.js';
|
|
7
|
+
import { redactionMiddleware } from './secrets/redaction.js';
|
|
8
|
+
/**
|
|
9
|
+
* The ClearHand local server. Binds to 127.0.0.1 ONLY — the dashboard and API
|
|
10
|
+
* are never exposed to the network. All JSON/text responses pass through the
|
|
11
|
+
* redaction middleware before leaving the process.
|
|
12
|
+
*/
|
|
13
|
+
export async function startServer(opts = {}) {
|
|
14
|
+
const ctx = await createAppContext();
|
|
15
|
+
const port = opts.port ?? Number(process.env.CLEARHAND_PORT ?? ctx.config.port);
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(express.json({ limit: '2mb' }));
|
|
18
|
+
app.use(redactionMiddleware(ctx.secrets));
|
|
19
|
+
app.use('/api', apiRouter(ctx));
|
|
20
|
+
// Dashboard static assets (built by `npm run build:dashboard` into public/).
|
|
21
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const candidates = [
|
|
23
|
+
path.resolve(here, '../../public'), // dist/server → public
|
|
24
|
+
path.resolve(here, '../../../public'), // src/server via tsx → public
|
|
25
|
+
];
|
|
26
|
+
const publicDir = candidates.find((p) => fs.existsSync(path.join(p, 'index.html')));
|
|
27
|
+
if (publicDir) {
|
|
28
|
+
app.use(express.static(publicDir));
|
|
29
|
+
// SPA fallback for client-side routes.
|
|
30
|
+
app.get(/^\/(?!api\/).*/, (_req, res) => res.sendFile(path.join(publicDir, 'index.html')));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
app.get('/', (_req, res) => res
|
|
34
|
+
.status(200)
|
|
35
|
+
.send('ClearHand server is running. Dashboard assets not built yet — run `npm run build:dashboard`.'));
|
|
36
|
+
}
|
|
37
|
+
return await new Promise((resolve) => {
|
|
38
|
+
const server = app.listen(port, '127.0.0.1', () => {
|
|
39
|
+
console.log(`[clearhand] server on http://localhost:${port}${ctx.demo ? ' (DEMO MODE)' : ''}`);
|
|
40
|
+
console.log(`[clearhand] secrets backend: ${ctx.secrets.backendName}`);
|
|
41
|
+
resolve({ port, close: () => server.close() });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
46
|
+
if (isMain) {
|
|
47
|
+
startServer().catch((err) => {
|
|
48
|
+
console.error('[clearhand] failed to start:', err);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
51
|
+
}
|