@pithy-sh/support 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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +68 -0
  4. package/pithy.manifest.json +40 -0
  5. package/src/ai/classify.ts +239 -0
  6. package/src/attachment/store.ts +78 -0
  7. package/src/audit/actions.ts +71 -0
  8. package/src/capability.ts +293 -0
  9. package/src/client/projection.ts +60 -0
  10. package/src/cloudflare-test.d.ts +15 -0
  11. package/src/config/config.ts +400 -0
  12. package/src/data/attachment.ts +65 -0
  13. package/src/data/billingScope.ts +32 -0
  14. package/src/data/categories.ts +117 -0
  15. package/src/data/classification.ts +50 -0
  16. package/src/data/enums.ts +90 -0
  17. package/src/data/flag.ts +37 -0
  18. package/src/data/message.ts +224 -0
  19. package/src/data/tables.ts +58 -0
  20. package/src/data/thread.ts +138 -0
  21. package/src/error/errors.ts +133 -0
  22. package/src/http/guards.ts +59 -0
  23. package/src/http/handlers.ts +418 -0
  24. package/src/http/resolve.ts +109 -0
  25. package/src/http/responses.ts +506 -0
  26. package/src/http/routes.ts +272 -0
  27. package/src/http/schemas.ts +251 -0
  28. package/src/http/scopes.ts +117 -0
  29. package/src/http/views.ts +169 -0
  30. package/src/inbound/authenticity.ts +114 -0
  31. package/src/inbound/guard.ts +127 -0
  32. package/src/inbound/handler.ts +102 -0
  33. package/src/inbound/ingest.ts +548 -0
  34. package/src/inbound/recipient.ts +67 -0
  35. package/src/index.ts +63 -0
  36. package/src/link/sender.ts +334 -0
  37. package/src/migrations/0001_threads.ts +296 -0
  38. package/src/mime/address.ts +37 -0
  39. package/src/mime/parse.ts +299 -0
  40. package/src/mime/sanitize.ts +253 -0
  41. package/src/mime/threading.ts +127 -0
  42. package/src/mime/truncate.ts +55 -0
  43. package/src/provision/provisionSupport.ts +179 -0
  44. package/src/provision/resolveSupportConfig.ts +67 -0
  45. package/src/reply/send.ts +322 -0
  46. package/src/reply/snippets.ts +167 -0
  47. package/src/secret/registry.ts +24 -0
  48. package/src/seeds/example.ts +385 -0
  49. package/src/store/paging.ts +22 -0
  50. package/src/store/search.ts +197 -0
  51. package/src/store/searchIndex.ts +71 -0
  52. package/src/store/threads.ts +452 -0
  53. package/src/submission/encoding.ts +66 -0
  54. package/src/submission/guard.ts +120 -0
  55. package/src/submission/submit.ts +539 -0
  56. package/src/version.generated.ts +16 -0
  57. package/src/workflows/classify.ts +164 -0
  58. package/src/workflows/retryPolicy.ts +48 -0
  59. package/src/workflows/specs.ts +61 -0
  60. package/src/workflows/worker.ts +82 -0
  61. package/src/workflows/wrangler.jsonc +46 -0
@@ -0,0 +1,164 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SupportAi } from "../ai/classify";
5
+ import { classifyMessage } from "../ai/classify";
6
+ import type { SupportAiConfig } from "../config/config";
7
+ import type { SupportCategories } from "../data/categories";
8
+ import { SupportClassification } from "../data/classification";
9
+ import { SPAM } from "../data/enums";
10
+ import {
11
+ SUPPORT_CLASSIFICATIONS_TABLE,
12
+ SUPPORT_MESSAGES_TABLE,
13
+ SUPPORT_THREADS_TABLE,
14
+ type SupportDatabase,
15
+ } from "../data/tables";
16
+
17
+ /**
18
+ * The classification orchestration — the pure, testable core the Workflow step runs.
19
+ *
20
+ * Reads a message, asks the model about it, **appends** a classification row, and denormalizes the
21
+ * answer onto its thread. The two writes are deliberate and asymmetric: the history is append-only
22
+ * so a model upgrade is visible in the data rather than a silent rewrite, and the thread carries only
23
+ * the current answer because that is what the inbox query reads.
24
+ *
25
+ * Idempotent by construction. Re-running over the same message appends a second row and overwrites
26
+ * the same three thread columns with a fresh judgment — which is what makes a Workflow retry, a
27
+ * manual reclassify, and a post-upgrade backfill the same operation.
28
+ */
29
+
30
+ /** Everything classification needs, all injectable. */
31
+ export interface ClassifyDeps {
32
+ /** The support tables. */
33
+ db: SupportDatabase;
34
+ /** The Workers AI binding. */
35
+ ai: SupportAi;
36
+ /** The effective taxonomy — the defaults plus the adopter's. */
37
+ categories: SupportCategories;
38
+ /** Model, bounds, and temperature. */
39
+ ai_config: SupportAiConfig;
40
+ /**
41
+ * Archive a thread the classifier calls `spam`, so it never reaches the open inbox.
42
+ *
43
+ * **Archived, never deleted.** The classifier is wrong sometimes, and a pass that destroyed mail
44
+ * would be untrustworthy the first time it was — whereas an archived thread is still readable under
45
+ * the archived filter and one click from being back. That is what makes `spam` a filter rather than
46
+ * a bin, and it is why this is safe to have on by default.
47
+ */
48
+ archiveSpam: boolean;
49
+ /** Generate a row id. */
50
+ newId: () => string;
51
+ /** Now. */
52
+ now: () => Date;
53
+ }
54
+
55
+ /** What one classification run did. `null` when the message no longer exists. */
56
+ export interface ClassifyOutcome {
57
+ /** The thread the classification landed on. */
58
+ threadId: string;
59
+ /** The category chosen. */
60
+ category: string;
61
+ /** The model's confidence. */
62
+ confidence: number;
63
+ /** The model that produced it. */
64
+ model: string;
65
+ /** Whether this run archived the thread because it classified as spam. */
66
+ archivedAsSpam: boolean;
67
+ }
68
+
69
+ /** Classify one stored message and write the result. Returns null when the message is gone. */
70
+ export async function runClassification(deps: ClassifyDeps, messageId: string): Promise<ClassifyOutcome | null> {
71
+ const message = await deps.db
72
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
73
+ .select(["id", "threadId", "subject", "textBody"])
74
+ .where("id", "=", messageId)
75
+ .executeTakeFirst();
76
+ // Not an error. A Workflow instance can outlive the row that started it, and a retry after a
77
+ // rollback is the ordinary way that happens.
78
+ if (!message) return null;
79
+
80
+ const result = await classifyMessage(deps.ai, {
81
+ categories: deps.categories,
82
+ subject: message.subject,
83
+ body: message.textBody,
84
+ model: deps.ai_config.model,
85
+ maxChars: deps.ai_config.maxChars,
86
+ temperature: deps.ai_config.temperature,
87
+ });
88
+
89
+ const now = deps.now();
90
+
91
+ await deps.db
92
+ .insertInto(SUPPORT_CLASSIFICATIONS_TABLE)
93
+ .values(
94
+ SupportClassification.encode({
95
+ id: deps.newId(),
96
+ threadId: message.threadId,
97
+ messageId: message.id,
98
+ category: result.category,
99
+ priority: result.priority,
100
+ sentiment: result.sentiment,
101
+ confidence: result.confidence,
102
+ model: result.model,
103
+ createdAt: now,
104
+ }),
105
+ )
106
+ .execute();
107
+
108
+ // Spam gets archived in the same write that classifies it, not a second pass — otherwise there is a
109
+ // window where it sits in somebody's open inbox, which is the whole thing this avoids.
110
+ const archivedAsSpam = deps.archiveSpam && result.category === SPAM;
111
+
112
+ await deps.db
113
+ .updateTable(SUPPORT_THREADS_TABLE)
114
+ .set({
115
+ category: result.category,
116
+ priority: result.priority,
117
+ sentiment: result.sentiment,
118
+ confidence: result.confidence,
119
+ model: result.model,
120
+ classifiedAt: now.getTime(),
121
+ updatedAt: now.getTime(),
122
+ ...(archivedAsSpam
123
+ ? {
124
+ archived: 1 as const,
125
+ archivedAt: now.getTime(),
126
+ // The model, named as the actor. An operator looking at why a thread is archived should
127
+ // see that nobody decided it — and `archivedBy` is exactly where they will look.
128
+ archivedBy: result.model,
129
+ }
130
+ : {}),
131
+ })
132
+ .where("id", "=", message.threadId)
133
+ .execute();
134
+
135
+ return {
136
+ threadId: message.threadId,
137
+ category: result.category,
138
+ confidence: result.confidence,
139
+ model: result.model,
140
+ archivedAsSpam,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The message a manual reclassify runs against: a thread's most recent **inbound** message.
146
+ *
147
+ * Inbound, not latest — a thread whose last message is our own reply would otherwise be reclassified
148
+ * on the text the adopter wrote, which says nothing about what the customer wanted.
149
+ */
150
+ export async function latestInboundMessageId(db: SupportDatabase, threadId: string): Promise<string | undefined> {
151
+ const row = await db
152
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
153
+ .select(["id"])
154
+ .where("threadId", "=", threadId)
155
+ .where("direction", "=", "inbound")
156
+ .orderBy("receivedAt", "desc")
157
+ // Tiebroken on id: two messages can share a millisecond (a redelivery burst, or a fixed clock in
158
+ // a test), and without it "the latest inbound message" is whichever the database felt like —
159
+ // so a reclassify or a reply could thread against the wrong parent.
160
+ .orderBy("id", "desc")
161
+ .limit(1)
162
+ .executeTakeFirst();
163
+ return row?.id;
164
+ }
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
5
+
6
+ /**
7
+ * **What a classification retries, and what it refuses to.**
8
+ *
9
+ * `ai/classify.ts` already drew this line and drew it correctly: a model that returns prose, an invented
10
+ * label, or an envelope nobody documented yields `uncategorized` at zero confidence and never throws,
11
+ * because throwing would burn a retry budget on a model that is going to say the same thing next time.
12
+ * The one case it deliberately does not swallow is a binding that is *unavailable*. This is that
13
+ * sentence in the form the durable step reads (pithy-sh/pithy#348).
14
+ *
15
+ * ## Retryable, and why
16
+ *
17
+ * - **`core/upstream_failed`** — Workers AI rejected the call. Raised at exactly one place, the wrap
18
+ * around `ai.run` in `classifyMessage`, and it means the model did not answer at all. That is the
19
+ * whole population of transient faults in a classification, and the code is what lets the step tell
20
+ * it from a bad answer — a raw throw would be `unclassified`, and unclassified is terminal.
21
+ * - **A transient D1 fault** — the message read, the appended history row, the thread denormalization.
22
+ * Classified in core by `withD1Retry`, never restated here.
23
+ *
24
+ * ## Terminal, and why
25
+ *
26
+ * - **`support/not_found`** — a thread or message that is gone. `runClassification` does not even raise
27
+ * it for the message it was started for: a missing row returns `null`, because an instance outliving
28
+ * the row that started it is the ordinary way a rollback looks.
29
+ * - **`support/classification_failed`** — the AI binding is absent from the env. A binding does not
30
+ * appear on the fourth attempt; this wants `pithy support provision`.
31
+ * - **`support/invalid_category`** — the adopter's taxonomy will not validate. Config, and identical
32
+ * next time.
33
+ * - **`support/unparseable_message`, `support/rejected`, `support/reply_failed`** — all inbound-path and
34
+ * reply-path refusals. None runs inside the classify step.
35
+ * - **`validation/invalid_input`** — a payload the schema refuses.
36
+ *
37
+ * **Re-running is free, which is what makes the retryable entry safe.** Classification is idempotent by
38
+ * construction: a second pass appends a second history row and overwrites the same three thread columns
39
+ * with a fresh judgment, which is why a Workflow retry, a manual reclassify, and a post-upgrade
40
+ * backfill are the same operation.
41
+ */
42
+ export const supportWorkflowRetry: WorkflowRetryPolicy = {
43
+ capability: "support",
44
+ retryable: {
45
+ "core/upstream_failed":
46
+ "The model did not answer at all; classification is idempotent, so asking again costs one call.",
47
+ },
48
+ };
@@ -0,0 +1,61 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
5
+ import type { WorkflowRegistry, WorkflowSpecMap } from "@pithy-sh/core/src/workflow/spec";
6
+ import { z } from "zod";
7
+
8
+ /**
9
+ * The one durable job support owns, declared once.
10
+ *
11
+ * **Classification is a Workflow, not part of the `email()` handler**, and that is a hard constraint
12
+ * rather than a preference. An inbound handler runs under a tight CPU budget; a model call is
13
+ * hundreds of milliseconds of wall time it cannot spend, and a model that is slow or briefly
14
+ * unavailable would take the *persistence* of the message down with it. So the handler does the
15
+ * cheap, must-not-fail half — parse, bound, store — and hands off. A classification that fails
16
+ * retries against a message that is already safely in D1.
17
+ *
18
+ * `optional: true`: the Workflow lives in the prebuilt support worker that `pithy support provision`
19
+ * deploys, so a project that has not provisioned must still boot and still receive mail. An absent
20
+ * binding degrades to a logged skip and a thread that stays `uncategorized` — which is exactly the
21
+ * state a classification is allowed to be in.
22
+ */
23
+
24
+ /** The capability name — the first segment of the dispatch key and of every deployed workflow name. */
25
+ export const SUPPORT_CAPABILITY = "support";
26
+
27
+ /** Which message to classify. */
28
+ export const SupportClassifyParams = z
29
+ .object({
30
+ messageId: z
31
+ .string()
32
+ .min(1)
33
+ .describe(
34
+ "The `pithy_support_messages.id` to classify. The Workflow reads that message, asks the model about it, appends a classification row, and denormalizes the answer onto its thread.",
35
+ ),
36
+ })
37
+ .describe("The instance parameters of the classification Workflow — the one message it runs against.");
38
+ export type SupportClassifyParams = z.infer<typeof SupportClassifyParams>;
39
+
40
+ /** Support's durable jobs, keyed by job name. The key is the second segment of the `support/<job>` dispatch key. */
41
+ export const supportWorkflows = {
42
+ classify: {
43
+ binding: "SUPPORT_CLASSIFY",
44
+ className: "SupportClassifyWorkflow",
45
+ params: SupportClassifyParams,
46
+ optional: true,
47
+ },
48
+ } as const satisfies WorkflowSpecMap;
49
+
50
+ /**
51
+ * Support's jobs as a dispatch registry, keyed `support/<job>`. Built here rather than through
52
+ * `composeWorkflows` because the inbound handler dispatches from inside the capability, before any
53
+ * project-wide registry exists — and the key format comes from core's {@link workflowKey} either
54
+ * way, so the two cannot drift.
55
+ */
56
+ export const supportWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
57
+ Object.entries(supportWorkflows).map(([job, spec]) => {
58
+ const key = workflowKey(SUPPORT_CAPABILITY, job);
59
+ return [key, { key, capability: SUPPORT_CAPABILITY, job, spec }];
60
+ }),
61
+ );
@@ -0,0 +1,82 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
5
+ import { NonRetryableError } from "cloudflare:workflows";
6
+ import type { D1Database } from "@cloudflare/workers-types";
7
+ import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
8
+ import { workflowHostEntry } from "@pithy-sh/core/src/workflow/hostEntry";
9
+ import type { SupportAi } from "../ai/classify";
10
+ import { SupportConfig } from "../config/config";
11
+ import { resolveCategories } from "../data/categories";
12
+ import { supportDatabase } from "../data/tables";
13
+ import { runClassification } from "./classify";
14
+ import { supportWorkflowRetry } from "./retryPolicy";
15
+ import { SUPPORT_CAPABILITY } from "./specs";
16
+
17
+ /**
18
+ * The prebuilt support worker. `pithy support provision` deploys one per environment; the adopter
19
+ * authors no code for it. It hosts the classification Workflow — a thin durable shell around the
20
+ * tested orchestration in `classify.ts`. The app worker receives the mail, stores it, and starts an
21
+ * instance.
22
+ *
23
+ * This module imports `cloudflare:workers`, so it runs only in the Workers runtime and is excluded
24
+ * from the node `.describe()` meta-test.
25
+ *
26
+ * **The effective taxonomy arrives as config, not as code.** `SUPPORT_CONFIG` carries the adopter's
27
+ * own categories, so their `tournament_dispute` reaches the prompt without this worker importing
28
+ * anything of theirs — which it could not do anyway, since it is deployed from this package.
29
+ *
30
+ * **The default export is what makes this an ES module** (#426). It exports one Workflow class and has
31
+ * no cron, so until now it exported no default — and wrangler infers a worker's module format from
32
+ * exactly that, so the build read it as a service worker and refused `cloudflare:workers` outright.
33
+ * The host did not build, `pithy dev` carried on past it, and classification silently never ran. The
34
+ * refusal it exports is the honest body for a host with no request surface; see
35
+ * `@pithy-sh/core/src/workflow/hostEntry`.
36
+ */
37
+
38
+ /** The support worker's env: the app database, the AI binding, and the serialized config. */
39
+ export interface SupportWorkerEnv {
40
+ /** The app database the support tables live in. */
41
+ DB: D1Database;
42
+ /** The Workers AI binding. */
43
+ AI: SupportAi;
44
+ /** The resolved support config as a JSON string, filled at provision. */
45
+ SUPPORT_CONFIG?: string;
46
+ }
47
+
48
+ /** Classify one stored message and write the result. */
49
+ export class SupportClassifyWorkflow extends WorkflowEntrypoint<SupportWorkerEnv, { messageId: string }> {
50
+ override async run(event: WorkflowEvent<{ messageId: string }>, step: WorkflowStep): Promise<void> {
51
+ const config = SupportConfig.parse(this.env.SUPPORT_CONFIG ? JSON.parse(this.env.SUPPORT_CONFIG) : {});
52
+ const deps = {
53
+ db: supportDatabase(this.env.DB),
54
+ ai: this.env.AI,
55
+ categories: resolveCategories(config.categories),
56
+ ai_config: config.ai,
57
+ archiveSpam: config.guard.archiveSpam,
58
+ newId: () => crypto.randomUUID(),
59
+ now: () => new Date(),
60
+ };
61
+
62
+ // One step. The unit of retry is the whole judgment, because a classification that half-ran —
63
+ // a history row with no thread update — would leave the inbox disagreeing with its own audit
64
+ // trail, and re-running the model is cheap enough that splitting it buys nothing.
65
+ // Under `supportWorkflowRetry`: a model that could not be reached re-drives, and everything else
66
+ // fails at once. `classifyMessage` already refuses to throw on a bad *answer*, so the only fault
67
+ // that reaches this classifier as retryable is the binding not answering. See `retryPolicy.ts`.
68
+ await classifiedSteps(step, supportWorkflowRetry, NonRetryableError).do(
69
+ `classify-${event.payload.messageId}`,
70
+ async () => {
71
+ await runClassification(deps, event.payload.messageId);
72
+ },
73
+ );
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The module's default export, and therefore its format. See `hostEntry` for why a Workflow host needs
79
+ * one at all, and why this one refuses rather than being empty: nothing reaches this worker over HTTP —
80
+ * the app worker stores the message and starts an instance on the `SUPPORT_CLASSIFY` binding.
81
+ */
82
+ export default workflowHostEntry(SUPPORT_CAPABILITY);
@@ -0,0 +1,46 @@
1
+ {
2
+ // The prebuilt support classification worker. Like the media and email workers, this is a TEMPLATE,
3
+ // not a wrangler env-stanza file: staging and prod are separate workers. `pithy support
4
+ // provision` resolves it into one complete config per environment — filling the `<...>` placeholders
5
+ // — and deploys each with `wrangler deploy --config <resolved>`. The adopter authors none of it.
6
+ //
7
+ // It hosts the one classification Workflow. The app worker receives the mail, stores it, and starts
8
+ // an instance; this worker is what talks to the model. That split is the whole reason classification
9
+ // is durable: an inbound handler has a tight CPU budget and a model call does not fit in it.
10
+ // Resolved per project and env → <project>-staging-support / <project>-prod-support. Worker
11
+ // script names are account-scoped, so the project segment is what stops a second Pithy project's
12
+ // deploy overwriting this one's running worker instead of colliding with it.
13
+ "name": "pithy-support",
14
+ "main": "./worker.ts",
15
+ // The compatibility date every Worker in this repository runs on. Stated once in the repository
16
+ // root's `compatibility.ts` and copied here because JSONC cannot import it —
17
+ // `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
18
+ "compatibility_date": "2026-06-01",
19
+ "compatibility_flags": ["nodejs_compat"],
20
+
21
+ // No public URL. The support routes live in the app worker; this one is reached only by Workflow
22
+ // dispatch.
23
+ "workers_dev": false,
24
+
25
+ // The app database the support tables live in. No secrets database: this worker reads a message and
26
+ // writes a classification, and neither needs a credential — the AI binding is the whole dependency.
27
+ "d1_databases": [{ "binding": "DB", "database_name": "pithy-app", "database_id": "<filled-at-provision>" }],
28
+
29
+ // The Workers AI binding — classification runs on the adopter's own inference, on their own bill,
30
+ // and their customers' support mail never leaves their account.
31
+ "ai": { "binding": "AI" },
32
+
33
+ // The classification Workflow this worker hosts. `class_name` matches the exported
34
+ // WorkflowEntrypoint subclass; `binding` is what the app worker dispatches to after storing a message.
35
+ "workflows": [
36
+ { "binding": "SUPPORT_CLASSIFY", "name": "pithy-support-classify", "class_name": "SupportClassifyWorkflow" }
37
+ ],
38
+
39
+ "vars": {
40
+ // The resolved SupportConfig as one JSON blob, filled at provision from the app's support() config.
41
+ // The worker parses and validates it; absent falls back to the defaults. It carries the effective
42
+ // taxonomy, so an adopter's own categories reach the prompt without this worker importing their code.
43
+ "SUPPORT_CONFIG": "<filled-at-provision>",
44
+ "ENVIRONMENT": "<filled-at-provision>"
45
+ }
46
+ }