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,252 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { recordEgress } from '../trust/egress.js';
|
|
3
|
+
export const STRIPE_SECRET_NAME = 'stripe_api_key';
|
|
4
|
+
const STRIPE_HOST = 'api.stripe.com';
|
|
5
|
+
const API_VERSION = '2025-05-28.basil';
|
|
6
|
+
/**
|
|
7
|
+
* Every Stripe call goes through here: the key is decrypted just-in-time with
|
|
8
|
+
* a stated purpose (which lands in the secret access log) and the egress
|
|
9
|
+
* ledger records the host before the call is made.
|
|
10
|
+
*/
|
|
11
|
+
function client(db, secrets, purpose, ref) {
|
|
12
|
+
const key = secrets.use(STRIPE_SECRET_NAME, purpose, ref);
|
|
13
|
+
recordEgress(db, STRIPE_HOST, purpose);
|
|
14
|
+
return new Stripe(key, { apiVersion: API_VERSION });
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Entry-time validation. Least privilege is policy: a full-access live key is
|
|
18
|
+
* accepted only with a loud warning; the recommended shape is a restricted
|
|
19
|
+
* key (rk_live_…) with customers:read, charges:read, refunds:write,
|
|
20
|
+
* subscriptions:write, invoices:read.
|
|
21
|
+
*/
|
|
22
|
+
export async function verifyStripeKey(db, rawKey) {
|
|
23
|
+
const keyType = rawKey.startsWith('rk_')
|
|
24
|
+
? 'restricted'
|
|
25
|
+
: rawKey.includes('_test_')
|
|
26
|
+
? 'test'
|
|
27
|
+
: rawKey.startsWith('sk_')
|
|
28
|
+
? 'full'
|
|
29
|
+
: 'unknown';
|
|
30
|
+
try {
|
|
31
|
+
recordEgress(db, STRIPE_HOST, 'verify stripe key');
|
|
32
|
+
const probe = new Stripe(rawKey, { apiVersion: API_VERSION });
|
|
33
|
+
await probe.balance.retrieve();
|
|
34
|
+
const check = { ok: true, keyType, accountHint: `…${rawKey.slice(-4)}` };
|
|
35
|
+
if (keyType === 'full') {
|
|
36
|
+
check.warning =
|
|
37
|
+
'This is a FULL-ACCESS live key. ClearHand works with a restricted key and recommends one: ' +
|
|
38
|
+
'create it at https://dashboard.stripe.com/apikeys/create with customers:read, charges:read, ' +
|
|
39
|
+
'refunds:write, subscriptions:write, invoices:read.';
|
|
40
|
+
}
|
|
41
|
+
return check;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return { ok: false, keyType, error: err instanceof Error ? err.message : String(err) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function subView(s) {
|
|
48
|
+
return {
|
|
49
|
+
id: s.id,
|
|
50
|
+
status: s.status,
|
|
51
|
+
cancel_at_period_end: s.cancel_at_period_end,
|
|
52
|
+
cancel_at: s.cancel_at,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The executor's Stripe ops. Every mutation returns a FRESH RETRIEVED view —
|
|
57
|
+
* not the mutation response — so the executor's confirm-before-success checks
|
|
58
|
+
* assert real post-mutation state (a 200 with an unchanged object must fail
|
|
59
|
+
* the check, not pass it).
|
|
60
|
+
*/
|
|
61
|
+
export function makeStripeExecOps(db, secrets) {
|
|
62
|
+
return {
|
|
63
|
+
async retrieveSubscription(subId, ref) {
|
|
64
|
+
const s = client(db, secrets, `pre-flight retrieve ${subId}`, ref);
|
|
65
|
+
return subView(await s.subscriptions.retrieve(subId));
|
|
66
|
+
},
|
|
67
|
+
async cancelNow(subId, ref) {
|
|
68
|
+
const s = client(db, secrets, `cancel subscription now ${subId}`, ref);
|
|
69
|
+
await s.subscriptions.cancel(subId, { invoice_now: false, prorate: false });
|
|
70
|
+
return subView(await s.subscriptions.retrieve(subId));
|
|
71
|
+
},
|
|
72
|
+
async cancelAtPeriodEnd(subId, ref) {
|
|
73
|
+
const s = client(db, secrets, `cancel at period end ${subId}`, ref);
|
|
74
|
+
await s.subscriptions.update(subId, { cancel_at_period_end: true });
|
|
75
|
+
return subView(await s.subscriptions.retrieve(subId));
|
|
76
|
+
},
|
|
77
|
+
async cancelAt(subId, epochSeconds, ref) {
|
|
78
|
+
const s = client(db, secrets, `schedule cancel ${subId}`, ref);
|
|
79
|
+
// proration_behavior 'none' is mandatory: omitting it generated a
|
|
80
|
+
// wrongful proration charge in the ancestor system (Incident A).
|
|
81
|
+
await s.subscriptions.update(subId, {
|
|
82
|
+
cancel_at: epochSeconds,
|
|
83
|
+
proration_behavior: 'none',
|
|
84
|
+
});
|
|
85
|
+
return subView(await s.subscriptions.retrieve(subId));
|
|
86
|
+
},
|
|
87
|
+
async retrieveCharge(chargeId, ref) {
|
|
88
|
+
const s = client(db, secrets, `retrieve charge ${chargeId}`, ref);
|
|
89
|
+
const ch = await s.charges.retrieve(chargeId);
|
|
90
|
+
return {
|
|
91
|
+
id: ch.id,
|
|
92
|
+
amount: ch.amount,
|
|
93
|
+
currency: ch.currency,
|
|
94
|
+
refunded: ch.refunded,
|
|
95
|
+
amount_refunded: ch.amount_refunded,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async createRefund(params, ref) {
|
|
99
|
+
const s = client(db, secrets, `execute refund on ${params.chargeId}`, ref);
|
|
100
|
+
const created = await s.refunds.create({
|
|
101
|
+
charge: params.chargeId,
|
|
102
|
+
...(params.amountCents !== undefined ? { amount: params.amountCents } : {}),
|
|
103
|
+
reason: 'requested_by_customer',
|
|
104
|
+
});
|
|
105
|
+
// Confirm-before-success: re-read the refund.
|
|
106
|
+
const confirmed = await s.refunds.retrieve(created.id);
|
|
107
|
+
if (confirmed.status !== 'succeeded' && confirmed.status !== 'pending') {
|
|
108
|
+
throw new Error(`refund ${created.id} did not confirm: status=${confirmed.status}`);
|
|
109
|
+
}
|
|
110
|
+
return { id: confirmed.id, status: confirmed.status ?? 'unknown', amount: confirmed.amount };
|
|
111
|
+
},
|
|
112
|
+
async latestPaidInvoiceCharge(subId, ref) {
|
|
113
|
+
const s = client(db, secrets, `resolve charge from ${subId}`, ref);
|
|
114
|
+
const invoices = await s.invoices.list({ subscription: subId, status: 'paid', limit: 1 });
|
|
115
|
+
const inv = invoices.data[0];
|
|
116
|
+
if (!inv)
|
|
117
|
+
return null;
|
|
118
|
+
if (typeof inv.charge === 'string')
|
|
119
|
+
return inv.charge;
|
|
120
|
+
if (inv.charge && typeof inv.charge === 'object')
|
|
121
|
+
return inv.charge.id;
|
|
122
|
+
// Newer API shapes hang the charge off the payment intent.
|
|
123
|
+
const pi = inv.payment_intent;
|
|
124
|
+
if (typeof pi === 'string') {
|
|
125
|
+
const intent = await s.paymentIntents.retrieve(pi);
|
|
126
|
+
const latest = intent.latest_charge;
|
|
127
|
+
return typeof latest === 'string' ? latest : (latest?.id ?? null);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Counts for the connect micro-payoff. Sampled lists; "+" marks a full page. */
|
|
134
|
+
export async function stripeInventory(db, secrets) {
|
|
135
|
+
const stripe = client(db, secrets, 'connect inventory (counts)');
|
|
136
|
+
const [products, prices, charges, refunds, disputes] = await Promise.all([
|
|
137
|
+
stripe.products.list({ active: true, limit: 100 }),
|
|
138
|
+
stripe.prices.list({ active: true, limit: 100 }),
|
|
139
|
+
stripe.charges.list({ limit: 100 }),
|
|
140
|
+
stripe.refunds.list({ limit: 100 }),
|
|
141
|
+
stripe.disputes.list({ limit: 100 }),
|
|
142
|
+
]);
|
|
143
|
+
const count = (l) => l.has_more ? `${l.data.length}+` : String(l.data.length);
|
|
144
|
+
return {
|
|
145
|
+
products: products.data.length,
|
|
146
|
+
prices: prices.data.length,
|
|
147
|
+
charges: count(charges),
|
|
148
|
+
refunds: count(refunds),
|
|
149
|
+
disputes: count(disputes),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export async function enrichFromStripe(db, secrets, customerEmail, ref) {
|
|
153
|
+
const stripe = client(db, secrets, 'enrich case (customer lookup)', ref);
|
|
154
|
+
const found = await stripe.customers.list({ email: customerEmail, limit: 5 });
|
|
155
|
+
const enrichment = {
|
|
156
|
+
found: found.data.length > 0,
|
|
157
|
+
fetchedAt: new Date().toISOString(),
|
|
158
|
+
customers: [],
|
|
159
|
+
};
|
|
160
|
+
for (const cust of found.data) {
|
|
161
|
+
const [subs, charges] = await Promise.all([
|
|
162
|
+
stripe.subscriptions.list({ customer: cust.id, status: 'all', limit: 10 }),
|
|
163
|
+
stripe.charges.list({ customer: cust.id, limit: 20 }),
|
|
164
|
+
]);
|
|
165
|
+
let ltv = 0;
|
|
166
|
+
let refundCount = 0;
|
|
167
|
+
let refundTotal = 0;
|
|
168
|
+
const recentCharges = charges.data.map((ch) => {
|
|
169
|
+
if (ch.status === 'succeeded')
|
|
170
|
+
ltv += ch.amount - ch.amount_refunded;
|
|
171
|
+
if (ch.amount_refunded > 0) {
|
|
172
|
+
refundCount += 1;
|
|
173
|
+
refundTotal += ch.amount_refunded;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
id: ch.id,
|
|
177
|
+
amountCents: ch.amount,
|
|
178
|
+
currency: ch.currency,
|
|
179
|
+
created: new Date(ch.created * 1000).toISOString(),
|
|
180
|
+
status: ch.status,
|
|
181
|
+
refunded: ch.refunded,
|
|
182
|
+
amountRefundedCents: ch.amount_refunded,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
enrichment.customers.push({
|
|
186
|
+
customerId: cust.id,
|
|
187
|
+
created: new Date(cust.created * 1000).toISOString(),
|
|
188
|
+
currency: cust.currency ?? null,
|
|
189
|
+
ltvCents: ltv,
|
|
190
|
+
subscriptions: subs.data.map((s) => {
|
|
191
|
+
const item = s.items.data[0];
|
|
192
|
+
return {
|
|
193
|
+
id: s.id,
|
|
194
|
+
status: s.status,
|
|
195
|
+
priceNickname: item?.price.nickname ?? null,
|
|
196
|
+
priceId: item?.price.id ?? '',
|
|
197
|
+
amountCents: item?.price.unit_amount ?? null,
|
|
198
|
+
interval: item?.price.recurring?.interval ?? null,
|
|
199
|
+
currentPeriodEnd: item?.current_period_end
|
|
200
|
+
? new Date(item.current_period_end * 1000).toISOString()
|
|
201
|
+
: null,
|
|
202
|
+
cancelAtPeriodEnd: s.cancel_at_period_end,
|
|
203
|
+
};
|
|
204
|
+
}),
|
|
205
|
+
recentCharges,
|
|
206
|
+
refunds: { count: refundCount, totalCents: refundTotal },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return enrichment;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Aggregate stats used by onboarding extraction: catalog + actual refund
|
|
213
|
+
* practice ("you refunded N% of requests, median full") so the policy
|
|
214
|
+
* interview can ask evidence-based questions instead of blank forms.
|
|
215
|
+
*/
|
|
216
|
+
export async function stripeBusinessSummary(db, secrets) {
|
|
217
|
+
const stripe = client(db, secrets, 'onboarding extraction (catalog + refund stats)');
|
|
218
|
+
const [products, prices, charges, refunds] = await Promise.all([
|
|
219
|
+
stripe.products.list({ active: true, limit: 50 }),
|
|
220
|
+
stripe.prices.list({ active: true, limit: 100 }),
|
|
221
|
+
stripe.charges.list({ limit: 100 }),
|
|
222
|
+
stripe.refunds.list({ limit: 100 }),
|
|
223
|
+
]);
|
|
224
|
+
const succeeded = charges.data.filter((c) => c.status === 'succeeded');
|
|
225
|
+
const fullRefunds = refunds.data.filter((r) => {
|
|
226
|
+
const ch = charges.data.find((c) => c.id === r.charge);
|
|
227
|
+
return ch ? r.amount >= ch.amount : false;
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
fetchedAt: new Date().toISOString(),
|
|
231
|
+
catalog: products.data.map((p) => ({
|
|
232
|
+
id: p.id,
|
|
233
|
+
name: p.name,
|
|
234
|
+
description: p.description,
|
|
235
|
+
prices: prices.data
|
|
236
|
+
.filter((pr) => pr.product === p.id)
|
|
237
|
+
.map((pr) => ({
|
|
238
|
+
id: pr.id,
|
|
239
|
+
nickname: pr.nickname,
|
|
240
|
+
amountCents: pr.unit_amount,
|
|
241
|
+
currency: pr.currency,
|
|
242
|
+
interval: pr.recurring?.interval ?? 'one_time',
|
|
243
|
+
})),
|
|
244
|
+
})),
|
|
245
|
+
refundPractice: {
|
|
246
|
+
sampledCharges: succeeded.length,
|
|
247
|
+
sampledRefunds: refunds.data.length,
|
|
248
|
+
fullRefundShare: refunds.data.length > 0 ? fullRefunds.length / refunds.data.length : null,
|
|
249
|
+
totalRefundedCents: refunds.data.reduce((s, r) => s + r.amount, 0),
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { openDb, logSecretAccess } from './db/index.js';
|
|
2
|
+
import { SecretStore } from './secrets/store.js';
|
|
3
|
+
import { loadConfig } from '../shared/config.js';
|
|
4
|
+
import { makeStripeExecOps, enrichFromStripe } from './connectors/stripe.js';
|
|
5
|
+
import { makeMailer } from './connectors/mailbox.js';
|
|
6
|
+
import { makeGithubOps } from './connectors/github.js';
|
|
7
|
+
import { mailboxConfig, githubConfig } from './services/connections.js';
|
|
8
|
+
import { makeDemoExecutorOps, seedDemoData } from './demo.js';
|
|
9
|
+
import { now } from './db/index.js';
|
|
10
|
+
/**
|
|
11
|
+
* Wires the deterministic core. Connector configs are resolved at CALL time
|
|
12
|
+
* (not boot time) so connecting Stripe or a mailbox in the dashboard takes
|
|
13
|
+
* effect on the next action without a server restart.
|
|
14
|
+
*/
|
|
15
|
+
export async function createAppContext(opts = {}) {
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
const db = openDb(opts.dbFile);
|
|
18
|
+
const secrets = await SecretStore.create({
|
|
19
|
+
storage: config.secretStorage,
|
|
20
|
+
onAccess: (a) => logSecretAccess(db, a),
|
|
21
|
+
});
|
|
22
|
+
const demo = config.demoMode;
|
|
23
|
+
if (demo)
|
|
24
|
+
seedDemoData(db);
|
|
25
|
+
const executorDeps = makeExecutorDeps(db, secrets, demo);
|
|
26
|
+
const approval = {
|
|
27
|
+
db,
|
|
28
|
+
executorDeps,
|
|
29
|
+
enrichCase: demo ? undefined : (caseId) => enrichCaseFromStripe(db, secrets, caseId),
|
|
30
|
+
};
|
|
31
|
+
return { db, secrets, config, approval, demo };
|
|
32
|
+
}
|
|
33
|
+
function makeExecutorDeps(db, secrets, demo) {
|
|
34
|
+
if (demo) {
|
|
35
|
+
const ops = makeDemoExecutorOps();
|
|
36
|
+
return { db, ...ops };
|
|
37
|
+
}
|
|
38
|
+
const stripe = makeStripeExecOps(db, secrets);
|
|
39
|
+
return {
|
|
40
|
+
db,
|
|
41
|
+
stripe,
|
|
42
|
+
mailer: {
|
|
43
|
+
async sendReply(params) {
|
|
44
|
+
const cfg = mailboxConfig(db);
|
|
45
|
+
if (!cfg)
|
|
46
|
+
throw new Error('mailbox not connected — connect it on the dashboard Connections page');
|
|
47
|
+
return makeMailer(db, secrets, cfg).sendReply(params);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
github: {
|
|
51
|
+
async createIssue(params, ref) {
|
|
52
|
+
const cfg = githubConfig(db);
|
|
53
|
+
if (!cfg)
|
|
54
|
+
throw new Error('GitHub not connected — connect it on the dashboard Connections page');
|
|
55
|
+
return makeGithubOps(db, secrets, cfg).createIssue(params, ref);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Case enrichment: server-owned. The `stripe` slice of prepared_context is
|
|
62
|
+
* written only here (agents may add notes/customer_history via PATCH
|
|
63
|
+
* /context, never Stripe-shaped data — LLM-supplied money data is
|
|
64
|
+
* radioactive at the trust boundary).
|
|
65
|
+
*/
|
|
66
|
+
export async function enrichCaseFromStripe(db, secrets, caseId) {
|
|
67
|
+
const row = db
|
|
68
|
+
.prepare(`SELECT c.id, cu.email FROM cases c JOIN customers cu ON cu.id = c.customer_id WHERE c.id = ?`)
|
|
69
|
+
.get(caseId);
|
|
70
|
+
if (!row)
|
|
71
|
+
throw new Error(`case ${caseId} not found or has no customer`);
|
|
72
|
+
const enrichment = await enrichFromStripe(db, secrets, row.email, `case:${caseId}`);
|
|
73
|
+
const existingRaw = db.prepare(`SELECT prepared_context_json FROM cases WHERE id = ?`).get(caseId).prepared_context_json;
|
|
74
|
+
let ctx = {};
|
|
75
|
+
try {
|
|
76
|
+
ctx = existingRaw ? JSON.parse(existingRaw) : {};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
ctx = {};
|
|
80
|
+
}
|
|
81
|
+
ctx.stripe = enrichment;
|
|
82
|
+
db.prepare(`UPDATE cases SET prepared_context_json = ?, enriched_at = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(ctx), now(), now(), caseId);
|
|
83
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { dbPath, ensureDir } from '../../shared/paths.js';
|
|
4
|
+
import { FINANCIAL_ACTION_TYPES } from '../../shared/actions.js';
|
|
5
|
+
/**
|
|
6
|
+
* SQLite via better-sqlite3, deliberately synchronous: the no-double-execute
|
|
7
|
+
* guarantee in the executor rests on single-writer synchronous transactions
|
|
8
|
+
* (the CAS claim in services/executor.ts). Do not swap this for an async
|
|
9
|
+
* driver or another store without re-proving that invariant under the new
|
|
10
|
+
* concurrency model (see docs/PORT_SPEC.md, porting note 1).
|
|
11
|
+
*/
|
|
12
|
+
const MIGRATIONS = [
|
|
13
|
+
// v1 — initial schema
|
|
14
|
+
`
|
|
15
|
+
CREATE TABLE customers (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
email TEXT NOT NULL UNIQUE,
|
|
18
|
+
name TEXT,
|
|
19
|
+
stripe_customer_id TEXT,
|
|
20
|
+
language TEXT,
|
|
21
|
+
notes TEXT,
|
|
22
|
+
created_at TEXT NOT NULL,
|
|
23
|
+
updated_at TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE cases (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
customer_id INTEGER REFERENCES customers(id),
|
|
29
|
+
source TEXT NOT NULL DEFAULT 'imap',
|
|
30
|
+
-- provider thread/conversation key. NOT unique: follow-ups on a closed
|
|
31
|
+
-- thread open a NEW case sharing the conversation (see newestCaseForConversation).
|
|
32
|
+
conversation_key TEXT,
|
|
33
|
+
subject TEXT,
|
|
34
|
+
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN
|
|
35
|
+
('new','processed','reviewing','pending','resolved','escalated','ignored')),
|
|
36
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')),
|
|
37
|
+
category TEXT,
|
|
38
|
+
language TEXT,
|
|
39
|
+
summary TEXT,
|
|
40
|
+
prepared_context_json TEXT,
|
|
41
|
+
enriched_at TEXT,
|
|
42
|
+
parent_case_id INTEGER REFERENCES cases(id),
|
|
43
|
+
merged_from_case_ids TEXT,
|
|
44
|
+
created_at TEXT NOT NULL,
|
|
45
|
+
updated_at TEXT NOT NULL
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX cases_conversation ON cases(source, conversation_key, id);
|
|
48
|
+
CREATE INDEX cases_status ON cases(status, updated_at);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE emails (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
case_id INTEGER NOT NULL REFERENCES cases(id),
|
|
53
|
+
direction TEXT NOT NULL CHECK (direction IN ('inbound','outbound')),
|
|
54
|
+
from_addr TEXT,
|
|
55
|
+
to_addr TEXT,
|
|
56
|
+
subject TEXT,
|
|
57
|
+
body_text TEXT,
|
|
58
|
+
body_html TEXT,
|
|
59
|
+
body_translated TEXT,
|
|
60
|
+
language TEXT,
|
|
61
|
+
provider_message_id TEXT,
|
|
62
|
+
in_reply_to TEXT,
|
|
63
|
+
received_at TEXT,
|
|
64
|
+
created_at TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
CREATE INDEX emails_case ON emails(case_id, received_at);
|
|
67
|
+
CREATE UNIQUE INDEX emails_provider_id ON emails(provider_message_id)
|
|
68
|
+
WHERE provider_message_id IS NOT NULL;
|
|
69
|
+
|
|
70
|
+
CREATE TABLE drafts (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
case_id INTEGER NOT NULL REFERENCES cases(id),
|
|
73
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
74
|
+
body_english TEXT NOT NULL,
|
|
75
|
+
body_translated TEXT,
|
|
76
|
+
target_language TEXT,
|
|
77
|
+
is_current INTEGER NOT NULL DEFAULT 1,
|
|
78
|
+
created_by TEXT NOT NULL DEFAULT 'agent' CHECK (created_by IN ('agent','human')),
|
|
79
|
+
created_at TEXT NOT NULL
|
|
80
|
+
);
|
|
81
|
+
CREATE INDEX drafts_case ON drafts(case_id, is_current);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE actions (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
85
|
+
case_id INTEGER NOT NULL REFERENCES cases(id),
|
|
86
|
+
action_type TEXT NOT NULL,
|
|
87
|
+
status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN
|
|
88
|
+
('proposed','approved','executing','executed','failed','rejected','superseded')),
|
|
89
|
+
execution_order INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
proposal_json TEXT NOT NULL,
|
|
91
|
+
modified_json TEXT,
|
|
92
|
+
confidence REAL,
|
|
93
|
+
policy_matched TEXT,
|
|
94
|
+
approved_by TEXT,
|
|
95
|
+
approved_at TEXT,
|
|
96
|
+
error_message TEXT,
|
|
97
|
+
result_json TEXT,
|
|
98
|
+
executed_at TEXT,
|
|
99
|
+
superseded_by_action_id INTEGER REFERENCES actions(id),
|
|
100
|
+
created_at TEXT NOT NULL
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX actions_case ON actions(case_id, status);
|
|
103
|
+
|
|
104
|
+
CREATE TABLE feedback_events (
|
|
105
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
106
|
+
case_id INTEGER REFERENCES cases(id),
|
|
107
|
+
action_id INTEGER,
|
|
108
|
+
event_type TEXT NOT NULL,
|
|
109
|
+
original_json TEXT,
|
|
110
|
+
modified_json TEXT,
|
|
111
|
+
diff_summary TEXT,
|
|
112
|
+
case_category TEXT,
|
|
113
|
+
created_at TEXT NOT NULL
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE learning_reviews (
|
|
117
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
case_id INTEGER REFERENCES cases(id),
|
|
119
|
+
category TEXT,
|
|
120
|
+
proposed_rule TEXT NOT NULL,
|
|
121
|
+
target_file TEXT NOT NULL,
|
|
122
|
+
target_section TEXT,
|
|
123
|
+
status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','approved','rejected')),
|
|
124
|
+
created_at TEXT NOT NULL,
|
|
125
|
+
resolved_at TEXT
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE calibrations (
|
|
129
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
130
|
+
customer_message TEXT NOT NULL,
|
|
131
|
+
context_note TEXT,
|
|
132
|
+
clearhand_draft TEXT NOT NULL,
|
|
133
|
+
actual_reply TEXT,
|
|
134
|
+
verdict TEXT CHECK (verdict IN (NULL,'fine','edited','wrong_policy','wrong_facts','wrong_tone')),
|
|
135
|
+
tags TEXT,
|
|
136
|
+
note TEXT,
|
|
137
|
+
created_at TEXT NOT NULL,
|
|
138
|
+
reviewed_at TEXT
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE audit_log (
|
|
142
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
143
|
+
at TEXT NOT NULL,
|
|
144
|
+
actor TEXT NOT NULL,
|
|
145
|
+
event TEXT NOT NULL,
|
|
146
|
+
case_id INTEGER,
|
|
147
|
+
action_id INTEGER,
|
|
148
|
+
phase TEXT,
|
|
149
|
+
detail TEXT
|
|
150
|
+
);
|
|
151
|
+
CREATE INDEX audit_case ON audit_log(case_id, at);
|
|
152
|
+
CREATE INDEX audit_action ON audit_log(action_id, at);
|
|
153
|
+
|
|
154
|
+
CREATE TABLE secret_access_log (
|
|
155
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
156
|
+
at TEXT NOT NULL,
|
|
157
|
+
secret_key TEXT NOT NULL,
|
|
158
|
+
purpose TEXT NOT NULL,
|
|
159
|
+
ref TEXT,
|
|
160
|
+
backend TEXT NOT NULL
|
|
161
|
+
);
|
|
162
|
+
CREATE INDEX secret_access_at ON secret_access_log(at);
|
|
163
|
+
|
|
164
|
+
CREATE TABLE egress_log (
|
|
165
|
+
host TEXT NOT NULL,
|
|
166
|
+
purpose TEXT NOT NULL,
|
|
167
|
+
first_at TEXT NOT NULL,
|
|
168
|
+
last_at TEXT NOT NULL,
|
|
169
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
170
|
+
PRIMARY KEY (host, purpose)
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE connections (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
status TEXT NOT NULL DEFAULT 'disconnected'
|
|
176
|
+
CHECK (status IN ('connected','disconnected','error','needs_reauth')),
|
|
177
|
+
config TEXT,
|
|
178
|
+
last_verified TEXT,
|
|
179
|
+
last_used TEXT,
|
|
180
|
+
last_error TEXT,
|
|
181
|
+
updated_at TEXT NOT NULL
|
|
182
|
+
);
|
|
183
|
+
`,
|
|
184
|
+
// v2 — onboarding state machine + progress event stream
|
|
185
|
+
`
|
|
186
|
+
CREATE TABLE onboarding (
|
|
187
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
188
|
+
state_json TEXT NOT NULL,
|
|
189
|
+
updated_at TEXT NOT NULL
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CREATE TABLE onboarding_events (
|
|
193
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
194
|
+
at TEXT NOT NULL,
|
|
195
|
+
stage TEXT NOT NULL,
|
|
196
|
+
kind TEXT NOT NULL,
|
|
197
|
+
payload TEXT
|
|
198
|
+
);
|
|
199
|
+
`,
|
|
200
|
+
// v3 — learning_reviews gains the 'applied' terminal state (the agent marks
|
|
201
|
+
// a review applied only after the overlay edit actually landed). SQLite
|
|
202
|
+
// can't alter a CHECK, so rebuild the table.
|
|
203
|
+
`
|
|
204
|
+
CREATE TABLE learning_reviews_v3 (
|
|
205
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
206
|
+
case_id INTEGER REFERENCES cases(id),
|
|
207
|
+
category TEXT,
|
|
208
|
+
proposed_rule TEXT NOT NULL,
|
|
209
|
+
target_file TEXT NOT NULL,
|
|
210
|
+
target_section TEXT,
|
|
211
|
+
status TEXT NOT NULL DEFAULT 'proposed'
|
|
212
|
+
CHECK (status IN ('proposed','approved','rejected','applied')),
|
|
213
|
+
created_at TEXT NOT NULL,
|
|
214
|
+
resolved_at TEXT
|
|
215
|
+
);
|
|
216
|
+
INSERT INTO learning_reviews_v3 SELECT * FROM learning_reviews;
|
|
217
|
+
DROP TABLE learning_reviews;
|
|
218
|
+
ALTER TABLE learning_reviews_v3 RENAME TO learning_reviews;
|
|
219
|
+
`,
|
|
220
|
+
];
|
|
221
|
+
export function openDb(file = dbPath()) {
|
|
222
|
+
ensureDir(path.dirname(file));
|
|
223
|
+
const db = new Database(file);
|
|
224
|
+
db.pragma('journal_mode = WAL');
|
|
225
|
+
db.pragma('foreign_keys = ON');
|
|
226
|
+
migrate(db);
|
|
227
|
+
return db;
|
|
228
|
+
}
|
|
229
|
+
/** In-memory database for tests. */
|
|
230
|
+
export function openMemoryDb() {
|
|
231
|
+
const db = new Database(':memory:');
|
|
232
|
+
db.pragma('foreign_keys = ON');
|
|
233
|
+
migrate(db);
|
|
234
|
+
return db;
|
|
235
|
+
}
|
|
236
|
+
function migrate(db) {
|
|
237
|
+
const current = db.pragma('user_version', { simple: true });
|
|
238
|
+
for (let v = current; v < MIGRATIONS.length; v++) {
|
|
239
|
+
const sql = MIGRATIONS[v];
|
|
240
|
+
if (!sql)
|
|
241
|
+
continue;
|
|
242
|
+
db.transaction(() => {
|
|
243
|
+
db.exec(sql);
|
|
244
|
+
db.pragma(`user_version = ${v + 1}`);
|
|
245
|
+
})();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function now() {
|
|
249
|
+
return new Date().toISOString();
|
|
250
|
+
}
|
|
251
|
+
export function audit(db, actor, event, opts = {}) {
|
|
252
|
+
db.prepare(`INSERT INTO audit_log (at, actor, event, case_id, action_id, phase, detail)
|
|
253
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(now(), actor, event, opts.caseId ?? null, opts.actionId ?? null, opts.phase ?? null, opts.detail !== undefined ? JSON.stringify(opts.detail) : null);
|
|
254
|
+
}
|
|
255
|
+
export function logSecretAccess(db, access) {
|
|
256
|
+
db.prepare(`INSERT INTO secret_access_log (at, secret_key, purpose, ref, backend) VALUES (?, ?, ?, ?, ?)`).run(access.at, access.key, access.purpose, access.ref ?? null, access.backend);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* THE financial-guard counter (strict variant — do not regress).
|
|
260
|
+
* `executed` = done; `rejected`/`superseded` = intentionally not doing it;
|
|
261
|
+
* everything else — including `failed` — still owes the customer. An earlier
|
|
262
|
+
* proposed-only counter in the ancestor system missed an approved-then-FAILED
|
|
263
|
+
* refund; `failed` stays in this list.
|
|
264
|
+
*/
|
|
265
|
+
export function countPendingFinancialActions(db, caseId) {
|
|
266
|
+
const row = db
|
|
267
|
+
.prepare(`SELECT COUNT(*) AS n FROM actions
|
|
268
|
+
WHERE case_id = ?
|
|
269
|
+
AND action_type IN (${[...FINANCIAL_ACTION_TYPES].map(() => '?').join(',')})
|
|
270
|
+
AND status IN ('proposed','approved','executing','failed')`)
|
|
271
|
+
.get(caseId, ...FINANCIAL_ACTION_TYPES);
|
|
272
|
+
return row.n;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Claim-on-first-touch: any working write to a `new` case promotes it to
|
|
276
|
+
* `reviewing` — the signal that a session already picked it up. In the
|
|
277
|
+
* ancestor system this stopped an in-server batch processor from
|
|
278
|
+
* double-processing; here it stops two concurrent agent sessions (a
|
|
279
|
+
* scheduled morning run plus a manual one) from both triaging the same
|
|
280
|
+
* case. Returns true when this call performed the claim.
|
|
281
|
+
*/
|
|
282
|
+
export function claimCaseIfNew(db, caseId) {
|
|
283
|
+
const res = db
|
|
284
|
+
.prepare(`UPDATE cases SET status = 'reviewing', updated_at = ? WHERE id = ? AND status = 'new'`)
|
|
285
|
+
.run(now(), caseId);
|
|
286
|
+
return res.changes === 1;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* A helpdesk conversation can back multiple cases over time (follow-ups on
|
|
290
|
+
* closed threads open fresh cases). New activity always attaches to the
|
|
291
|
+
* NEWEST case of the conversation — never the original.
|
|
292
|
+
*/
|
|
293
|
+
export function newestCaseForConversation(db, source, conversationKey) {
|
|
294
|
+
return db
|
|
295
|
+
.prepare(`SELECT id, status FROM cases
|
|
296
|
+
WHERE source = ? AND conversation_key = ?
|
|
297
|
+
ORDER BY id DESC LIMIT 1`)
|
|
298
|
+
.get(source, conversationKey);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Soft-supersede: replacing proposals never deletes them. Old rows flip to
|
|
302
|
+
* `superseded` with a pointer to the replacement and a feedback event, so
|
|
303
|
+
* dashboards and the learning loop keep the trail. The just-created
|
|
304
|
+
* replacement is excluded from the target set.
|
|
305
|
+
*/
|
|
306
|
+
export function softSupersedeProposals(db, caseId, actionType, newActionId, reason) {
|
|
307
|
+
const tx = db.transaction(() => {
|
|
308
|
+
const rows = db
|
|
309
|
+
.prepare(`SELECT id, proposal_json FROM actions
|
|
310
|
+
WHERE case_id = ? AND status = 'proposed' AND id != ?
|
|
311
|
+
${actionType ? 'AND action_type = ?' : ''}`)
|
|
312
|
+
.all(...(actionType ? [caseId, newActionId, actionType] : [caseId, newActionId]));
|
|
313
|
+
const upd = db.prepare(`UPDATE actions SET status = 'superseded', superseded_by_action_id = ?, error_message = ?
|
|
314
|
+
WHERE id = ?`);
|
|
315
|
+
const fb = db.prepare(`INSERT INTO feedback_events (case_id, action_id, event_type, original_json, diff_summary, created_at)
|
|
316
|
+
VALUES (?, ?, 'action_superseded', ?, ?, ?)`);
|
|
317
|
+
for (const row of rows) {
|
|
318
|
+
upd.run(newActionId, reason, row.id);
|
|
319
|
+
fb.run(caseId, row.id, row.proposal_json, `superseded by action ${newActionId}: ${reason}`, now());
|
|
320
|
+
}
|
|
321
|
+
return rows.length;
|
|
322
|
+
});
|
|
323
|
+
return tx();
|
|
324
|
+
}
|