@pithy-sh/support 0.5.1 → 0.6.1

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 (173) hide show
  1. package/README.md +8 -0
  2. package/dist/audit/actions.d.ts +9 -0
  3. package/dist/audit/actions.d.ts.map +1 -1
  4. package/dist/audit/actions.js +9 -0
  5. package/dist/capability.d.ts +2 -1
  6. package/dist/capability.d.ts.map +1 -1
  7. package/dist/capability.js +30 -6
  8. package/dist/config/config.d.ts +63 -2
  9. package/dist/config/config.d.ts.map +1 -1
  10. package/dist/config/config.js +63 -5
  11. package/dist/data/assignment.d.ts +43 -0
  12. package/dist/data/assignment.d.ts.map +1 -0
  13. package/dist/data/assignment.js +38 -0
  14. package/dist/data/embedding.d.ts +21 -0
  15. package/dist/data/embedding.d.ts.map +1 -0
  16. package/dist/data/embedding.js +23 -0
  17. package/dist/data/tables.d.ts +28 -0
  18. package/dist/data/tables.d.ts.map +1 -1
  19. package/dist/data/tables.js +10 -2
  20. package/dist/data/thread.d.ts +9 -4
  21. package/dist/data/thread.d.ts.map +1 -1
  22. package/dist/data/thread.js +9 -4
  23. package/dist/http/guards.d.ts +32 -14
  24. package/dist/http/guards.d.ts.map +1 -1
  25. package/dist/http/guards.js +44 -16
  26. package/dist/http/handlers.d.ts +35 -2
  27. package/dist/http/handlers.d.ts.map +1 -1
  28. package/dist/http/handlers.js +81 -8
  29. package/dist/http/resolve.d.ts +8 -0
  30. package/dist/http/resolve.d.ts.map +1 -1
  31. package/dist/http/resolve.js +20 -1
  32. package/dist/http/responses.d.ts +355 -0
  33. package/dist/http/responses.d.ts.map +1 -1
  34. package/dist/http/responses.js +64 -2
  35. package/dist/http/routes.d.ts +25 -11
  36. package/dist/http/routes.d.ts.map +1 -1
  37. package/dist/http/routes.js +34 -5
  38. package/dist/http/schemas.d.ts +67 -3
  39. package/dist/http/schemas.d.ts.map +1 -1
  40. package/dist/http/schemas.js +44 -5
  41. package/dist/http/scopes.d.ts +10 -5
  42. package/dist/http/scopes.d.ts.map +1 -1
  43. package/dist/http/scopes.js +42 -6
  44. package/dist/http/views.d.ts +9 -1
  45. package/dist/http/views.d.ts.map +1 -1
  46. package/dist/http/views.js +40 -1
  47. package/dist/inbound/ingest.d.ts +1 -1
  48. package/dist/inbound/ingest.d.ts.map +1 -1
  49. package/dist/inbound/ingest.js +3 -1
  50. package/dist/index.d.ts +5 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +6 -4
  53. package/dist/migrations/0002_assignment.d.ts +20 -0
  54. package/dist/migrations/0002_assignment.d.ts.map +1 -0
  55. package/dist/migrations/0002_assignment.js +45 -0
  56. package/dist/migrations/0003_embeddings.d.ts +15 -0
  57. package/dist/migrations/0003_embeddings.d.ts.map +1 -0
  58. package/dist/migrations/0003_embeddings.js +23 -0
  59. package/dist/migrations/set.d.ts +13 -0
  60. package/dist/migrations/set.d.ts.map +1 -0
  61. package/dist/migrations/set.js +21 -0
  62. package/dist/presence/room.d.ts +35 -0
  63. package/dist/presence/room.d.ts.map +1 -0
  64. package/dist/presence/room.js +66 -0
  65. package/dist/provision/provisionSupport.d.ts +32 -0
  66. package/dist/provision/provisionSupport.d.ts.map +1 -1
  67. package/dist/provision/provisionSupport.js +10 -2
  68. package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
  69. package/dist/provision/resolveSupportConfig.js +13 -2
  70. package/dist/purge/contribution.d.ts +49 -0
  71. package/dist/purge/contribution.d.ts.map +1 -0
  72. package/dist/purge/contribution.js +164 -0
  73. package/dist/secret/registry.d.ts +26 -2
  74. package/dist/secret/registry.d.ts.map +1 -1
  75. package/dist/secret/registry.js +15 -3
  76. package/dist/semantic/documents.d.ts +21 -0
  77. package/dist/semantic/documents.d.ts.map +1 -0
  78. package/dist/semantic/documents.js +128 -0
  79. package/dist/semantic/embed.d.ts +59 -0
  80. package/dist/semantic/embed.d.ts.map +1 -0
  81. package/dist/semantic/embed.js +59 -0
  82. package/dist/semantic/messageIndex.d.ts +46 -0
  83. package/dist/semantic/messageIndex.d.ts.map +1 -0
  84. package/dist/semantic/messageIndex.js +67 -0
  85. package/dist/semantic/query.d.ts +37 -0
  86. package/dist/semantic/query.d.ts.map +1 -0
  87. package/dist/semantic/query.js +46 -0
  88. package/dist/staff/staff.d.ts +22 -0
  89. package/dist/staff/staff.d.ts.map +1 -0
  90. package/dist/staff/staff.js +64 -0
  91. package/dist/store/assignment.d.ts +63 -0
  92. package/dist/store/assignment.d.ts.map +1 -0
  93. package/dist/store/assignment.js +86 -0
  94. package/dist/store/hybrid.d.ts +134 -0
  95. package/dist/store/hybrid.d.ts.map +1 -0
  96. package/dist/store/hybrid.js +185 -0
  97. package/dist/store/paging.d.ts +10 -0
  98. package/dist/store/paging.d.ts.map +1 -1
  99. package/dist/store/paging.js +11 -1
  100. package/dist/store/provisionIndex.d.ts +21 -0
  101. package/dist/store/provisionIndex.d.ts.map +1 -0
  102. package/dist/store/provisionIndex.js +36 -0
  103. package/dist/store/search.d.ts +18 -9
  104. package/dist/store/search.d.ts.map +1 -1
  105. package/dist/store/search.js +36 -34
  106. package/dist/store/searchIndex.d.ts +4 -4
  107. package/dist/store/searchIndex.js +4 -4
  108. package/dist/store/threads.d.ts +174 -8
  109. package/dist/store/threads.d.ts.map +1 -1
  110. package/dist/store/threads.js +33 -40
  111. package/dist/submission/submit.d.ts +1 -1
  112. package/dist/submission/submit.d.ts.map +1 -1
  113. package/dist/submission/submit.js +2 -1
  114. package/dist/test-utils/seed.d.ts +33 -0
  115. package/dist/test-utils/seed.d.ts.map +1 -0
  116. package/dist/test-utils/seed.js +102 -0
  117. package/dist/test-utils/semantic.d.ts +21 -0
  118. package/dist/test-utils/semantic.d.ts.map +1 -0
  119. package/dist/test-utils/semantic.js +107 -0
  120. package/dist/version.generated.d.ts +1 -1
  121. package/dist/version.generated.js +1 -1
  122. package/dist/workflows/specs.d.ts +23 -0
  123. package/dist/workflows/specs.d.ts.map +1 -1
  124. package/dist/workflows/specs.js +27 -7
  125. package/dist/workflows/worker.d.ts +15 -2
  126. package/dist/workflows/worker.d.ts.map +1 -1
  127. package/dist/workflows/worker.js +48 -4
  128. package/package.json +9 -7
  129. package/pithy.manifest.json +21 -3
  130. package/src/audit/actions.ts +9 -0
  131. package/src/capability.ts +43 -4
  132. package/src/config/config.ts +104 -5
  133. package/src/data/assignment.ts +55 -0
  134. package/src/data/embedding.ts +27 -0
  135. package/src/data/tables.ts +8 -0
  136. package/src/data/thread.ts +26 -4
  137. package/src/http/guards.ts +52 -15
  138. package/src/http/handlers.ts +125 -4
  139. package/src/http/resolve.ts +19 -0
  140. package/src/http/responses.ts +154 -0
  141. package/src/http/routes.ts +98 -12
  142. package/src/http/schemas.ts +117 -28
  143. package/src/http/scopes.ts +43 -6
  144. package/src/http/views.ts +44 -0
  145. package/src/inbound/ingest.ts +5 -2
  146. package/src/index.ts +5 -0
  147. package/src/migrations/0002_assignment.ts +65 -0
  148. package/src/migrations/0003_embeddings.ts +29 -0
  149. package/src/migrations/set.ts +21 -0
  150. package/src/presence/room.ts +94 -0
  151. package/src/provision/provisionSupport.ts +33 -1
  152. package/src/provision/resolveSupportConfig.ts +24 -13
  153. package/src/purge/contribution.ts +294 -0
  154. package/src/secret/registry.ts +17 -2
  155. package/src/semantic/documents.ts +178 -0
  156. package/src/semantic/embed.ts +99 -0
  157. package/src/semantic/messageIndex.ts +74 -0
  158. package/src/semantic/query.ts +66 -0
  159. package/src/staff/staff.ts +128 -0
  160. package/src/store/assignment.ts +179 -0
  161. package/src/store/hybrid.ts +314 -0
  162. package/src/store/paging.ts +14 -0
  163. package/src/store/provisionIndex.ts +46 -0
  164. package/src/store/search.ts +58 -37
  165. package/src/store/searchIndex.ts +4 -4
  166. package/src/store/threads.ts +91 -91
  167. package/src/submission/submit.ts +3 -2
  168. package/src/test-utils/seed.ts +141 -0
  169. package/src/test-utils/semantic.ts +110 -0
  170. package/src/version.generated.ts +1 -1
  171. package/src/workflows/specs.ts +34 -0
  172. package/src/workflows/worker.ts +84 -8
  173. package/src/workflows/wrangler.jsonc +8 -3
@@ -3,5 +3,5 @@
3
3
  /** This package's npm name — the join key against a release feed. */
4
4
  export declare const PACKAGE_NAME = "@pithy-sh/support";
5
5
  /** This package's version, stamped from its own package.json at generation time. */
6
- export declare const PACKAGE_VERSION = "0.5.1";
6
+ export declare const PACKAGE_VERSION = "0.6.1";
7
7
  //# sourceMappingURL=version.generated.d.ts.map
@@ -4,6 +4,6 @@
4
4
  /** This package's npm name — the join key against a release feed. */
5
5
  const PACKAGE_NAME = "@pithy-sh/support";
6
6
  /** This package's version, stamped from its own package.json at generation time. */
7
- const PACKAGE_VERSION = "0.5.1";
7
+ const PACKAGE_VERSION = "0.6.1";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
@@ -24,6 +24,19 @@ export declare const SupportClassifyParams: z.ZodObject<{
24
24
  messageId: z.ZodString;
25
25
  }, z.core.$strip>;
26
26
  export type SupportClassifyParams = z.infer<typeof SupportClassifyParams>;
27
+ /**
28
+ * One re-embed of the semantic message index, started by `pithy vector reprocess --index support-messages`.
29
+ *
30
+ * The parameters are the ones `pithy vector reprocess` sends every index, so the CLI dispatches a capability's
31
+ * index exactly as it dispatches one `vector()` owns. There is no `filter`: the vectors carry no metadata to
32
+ * filter on.
33
+ */
34
+ export declare const SupportReprocessParams: z.ZodObject<{
35
+ index: z.ZodLiteral<"support-messages">;
36
+ all: z.ZodOptional<z.ZodBoolean>;
37
+ pageSize: z.ZodOptional<z.ZodNumber>;
38
+ }, z.core.$strip>;
39
+ export type SupportReprocessParams = z.infer<typeof SupportReprocessParams>;
27
40
  /** Support's durable jobs, keyed by job name. The key is the second segment of the `support/<job>` dispatch key. */
28
41
  export declare const supportWorkflows: {
29
42
  readonly classify: {
@@ -34,6 +47,16 @@ export declare const supportWorkflows: {
34
47
  }, z.core.$strip>;
35
48
  readonly optional: true;
36
49
  };
50
+ readonly reprocess: {
51
+ readonly binding: "SUPPORT_REPROCESS";
52
+ readonly className: "SupportReprocessWorkflow";
53
+ readonly params: z.ZodObject<{
54
+ index: z.ZodLiteral<"support-messages">;
55
+ all: z.ZodOptional<z.ZodBoolean>;
56
+ pageSize: z.ZodOptional<z.ZodNumber>;
57
+ }, z.core.$strip>;
58
+ readonly optional: true;
59
+ };
37
60
  };
38
61
  /**
39
62
  * Support's jobs as a dispatch registry, keyed `support/<job>`. Built here rather than through
@@ -1 +1 @@
1
- {"version":3,"file":"specs.d.ts","sourceRoot":"","sources":["../../src/workflows/specs.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,MAAM,kCAAkC,CAAC;AAC1F,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;GAcG;AAEH,uGAAuG;AACvG,eAAO,MAAM,kBAAkB,YAAY,CAAC;AAE5C,iCAAiC;AACjC,eAAO,MAAM,qBAAqB;;iBASsE,CAAC;AACzG,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAE1E,oHAAoH;AACpH,eAAO,MAAM,gBAAgB;;iBAEzB,OAAO,EAAE,kBAAkB;iBAC3B,SAAS,EAAE,yBAAyB;iBACpC,MAAM;;;iBACN,QAAQ;;CAEwB,CAAC;AAErC;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,EAAE,gBAKrC,CAAC"}
1
+ {"version":3,"file":"specs.d.ts","sourceRoot":"","sources":["../../src/workflows/specs.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,MAAM,kCAAkC,CAAC;AAC1F,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;GAcG;AAEH,uGAAuG;AACvG,eAAO,MAAM,kBAAkB,YAAY,CAAC;AAE5C,iCAAiC;AACjC,eAAO,MAAM,qBAAqB;;iBASsE,CAAC;AACzG,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAE1E;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB;;;;iBAgB+C,CAAC;AACnF,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE5E,oHAAoH;AACpH,eAAO,MAAM,gBAAgB;;iBAEzB,OAAO,EAAE,kBAAkB;iBAC3B,SAAS,EAAE,yBAAyB;iBACpC,MAAM;;;iBACN,QAAQ;;;iBAKR,OAAO,EAAE,mBAAmB;iBAC5B,SAAS,EAAE,0BAA0B;iBACrC,MAAM;;;;;iBACN,QAAQ;;CAEwB,CAAC;AAErC;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,EAAE,gBAKrC,CAAC"}
@@ -22,13 +22,33 @@ import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
22
22
  const SUPPORT_CAPABILITY = "support";
23
23
  /** Which message to classify. */
24
24
  const SupportClassifyParams = z.object({ messageId: z.string().min(1).describe("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.") }).describe("The instance parameters of the classification Workflow — the one message it runs against.");
25
+ /**
26
+ * One re-embed of the semantic message index, started by `pithy vector reprocess --index support-messages`.
27
+ *
28
+ * The parameters are the ones `pithy vector reprocess` sends every index, so the CLI dispatches a capability's
29
+ * index exactly as it dispatches one `vector()` owns. There is no `filter`: the vectors carry no metadata to
30
+ * filter on.
31
+ */
32
+ const SupportReprocessParams = z.object({
33
+ index: z.literal("support-messages").describe("The index to re-embed. Support declares one, `support-messages`."),
34
+ all: z.boolean().optional().describe("Re-embed every inbound message, not only the ones whose stamp differs from the configured model. What a fresh index's backfill runs."),
35
+ pageSize: z.number().int().min(1).optional().describe("Messages per Workflow step. Defaults to the Vectorize upsert ceiling of 1,000.")
36
+ }).describe("The instance parameters of a re-embed of the support message index.");
25
37
  /** Support's durable jobs, keyed by job name. The key is the second segment of the `support/<job>` dispatch key. */
26
- const supportWorkflows = { classify: {
27
- binding: "SUPPORT_CLASSIFY",
28
- className: "SupportClassifyWorkflow",
29
- params: SupportClassifyParams,
30
- optional: true
31
- } };
38
+ const supportWorkflows = {
39
+ classify: {
40
+ binding: "SUPPORT_CLASSIFY",
41
+ className: "SupportClassifyWorkflow",
42
+ params: SupportClassifyParams,
43
+ optional: true
44
+ },
45
+ reprocess: {
46
+ binding: "SUPPORT_REPROCESS",
47
+ className: "SupportReprocessWorkflow",
48
+ params: SupportReprocessParams,
49
+ optional: true
50
+ }
51
+ };
32
52
  /**
33
53
  * Support's jobs as a dispatch registry, keyed `support/<job>`. Built here rather than through
34
54
  * `composeWorkflows` because the inbound handler dispatches from inside the capability, before any
@@ -45,4 +65,4 @@ const supportWorkflowRegistry = Object.fromEntries(Object.entries(supportWorkflo
45
65
  }];
46
66
  }));
47
67
  //#endregion
48
- export { SUPPORT_CAPABILITY, SupportClassifyParams, supportWorkflowRegistry, supportWorkflows };
68
+ export { SUPPORT_CAPABILITY, SupportClassifyParams, SupportReprocessParams, supportWorkflowRegistry, supportWorkflows };
@@ -2,7 +2,10 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
4
4
  import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { VectorStore } from "@pithy-sh/vector/src/index/index";
6
+ import { type ReprocessReport } from "@pithy-sh/vector/src/workflows/reprocess";
5
7
  import type { SupportAi } from "../ai/classify";
8
+ import { SupportReprocessParams } from "./specs";
6
9
  /**
7
10
  * The prebuilt support worker. `pithy support provision` deploys one per environment; the adopter
8
11
  * authors no code for it. It hosts the classification Workflow — a thin durable shell around the
@@ -23,12 +26,14 @@ import type { SupportAi } from "../ai/classify";
23
26
  * refusal it exports is the honest body for a host with no request surface; see
24
27
  * `@pithy-sh/core/src/workflow/hostEntry`.
25
28
  */
26
- /** The support worker's env: the app database, the AI binding, and the serialized config. */
29
+ /** The support worker's env: the app database, the AI binding, the message index, and the serialized config. */
27
30
  export interface SupportWorkerEnv {
28
31
  /** The app database the support tables live in. */
29
32
  DB: D1Database;
30
- /** The Workers AI binding. */
33
+ /** The Workers AI binding — classification and embedding both run on it. */
31
34
  AI: SupportAi;
35
+ /** The semantic message index. Bound only when `search.semantic` is on. */
36
+ SUPPORT_VECTORIZE?: VectorStore;
32
37
  /** The resolved support config as a JSON string, filled at provision. */
33
38
  SUPPORT_CONFIG?: string;
34
39
  }
@@ -40,6 +45,14 @@ export declare class SupportClassifyWorkflow extends WorkflowEntrypoint<SupportW
40
45
  messageId: string;
41
46
  }>, step: WorkflowStep): Promise<void>;
42
47
  }
48
+ /**
49
+ * Re-embed the semantic message index — `@pithy-sh/vector`'s own reprocess orchestration, over support's
50
+ * `DocumentStore`, one journalled page per step. `pithy vector reprocess --index support-messages` starts it,
51
+ * and so does `pithy support provision` once, to backfill an index it just created.
52
+ */
53
+ export declare class SupportReprocessWorkflow extends WorkflowEntrypoint<SupportWorkerEnv, SupportReprocessParams> {
54
+ run(event: WorkflowEvent<SupportReprocessParams>, step: WorkflowStep): Promise<ReprocessReport>;
55
+ }
43
56
  /**
44
57
  * The module's default export, and therefore its format. See `hostEntry` for why a Workflow host needs
45
58
  * one at all, and why this one refuses rather than being empty: nothing reaches this worker over HTTP —
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../src/workflows/worker.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE/F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAG5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAQhD;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,6FAA6F;AAC7F,MAAM,WAAW,gBAAgB;IAC/B,mDAAmD;IACnD,EAAE,EAAE,UAAU,CAAC;IACf,8BAA8B;IAC9B,EAAE,EAAE,SAAS,CAAC;IACd,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wDAAwD;AACxD,qBAAa,uBAAwB,SAAQ,kBAAkB,CAAC,gBAAgB,EAAE;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;IACvF,GAAG,CAAC,KAAK,EAAE,aAAa,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBjG;CACF;AAED;;;;GAIG"}
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../src/workflows/worker.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE/F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAM5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AACpE,OAAO,EAAE,KAAK,eAAe,EAAkB,MAAM,0CAA0C,CAAC;AAChG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAShD,OAAO,EAAsB,sBAAsB,EAAE,MAAM,SAAS,CAAC;AAErE;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,gHAAgH;AAChH,MAAM,WAAW,gBAAgB;IAC/B,mDAAmD;IACnD,EAAE,EAAE,UAAU,CAAC;IACf,4EAA4E;IAC5E,EAAE,EAAE,SAAS,CAAC;IACd,2EAA2E;IAC3E,iBAAiB,CAAC,EAAE,WAAW,CAAC;IAChC,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAOD,wDAAwD;AACxD,qBAAa,uBAAwB,SAAQ,kBAAkB,CAAC,gBAAgB,EAAE;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;IACvF,GAAG,CAAC,KAAK,EAAE,aAAa,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA+CjG;CACF;AAED;;;;GAIG;AACH,qBAAa,wBAAyB,SAAQ,kBAAkB,CAAC,gBAAgB,EAAE,sBAAsB,CAAC;IACzF,GAAG,CAAC,KAAK,EAAE,aAAa,CAAC,sBAAsB,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC,CA6B7G;CACF;AAED;;;;GAIG"}
@@ -3,18 +3,28 @@
3
3
  import { SupportConfig } from "../config/config.js";
4
4
  import { resolveCategories } from "../data/categories.js";
5
5
  import { supportDatabase } from "../data/tables.js";
6
- import { SUPPORT_CAPABILITY } from "./specs.js";
6
+ import { supportMessageIndex } from "../semantic/messageIndex.js";
7
+ import { SUPPORT_CAPABILITY, SupportReprocessParams } from "./specs.js";
7
8
  import { runClassification } from "./classify.js";
9
+ import { supportMessageDocuments } from "../semantic/documents.js";
10
+ import { runEmbedding } from "../semantic/embed.js";
8
11
  import { supportWorkflowRetry } from "./retryPolicy.js";
12
+ import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
13
+ import { createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
9
14
  import { WorkflowEntrypoint } from "cloudflare:workers";
10
15
  import { NonRetryableError } from "cloudflare:workflows";
11
16
  import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
12
17
  import { workflowHostEntry } from "@pithy-sh/core/src/workflow/hostEntry";
18
+ import { reprocessIndex } from "@pithy-sh/vector/src/workflows/reprocess";
13
19
  //#region src/workflows/worker.ts
20
+ /** Parse the config this worker was deployed with. Absent falls back to the defaults. */
21
+ function workerConfig(env) {
22
+ return SupportConfig.parse(env.SUPPORT_CONFIG ? JSON.parse(env.SUPPORT_CONFIG) : {});
23
+ }
14
24
  /** Classify one stored message and write the result. */
15
25
  var SupportClassifyWorkflow = class extends WorkflowEntrypoint {
16
26
  async run(event, step) {
17
- const config = SupportConfig.parse(this.env.SUPPORT_CONFIG ? JSON.parse(this.env.SUPPORT_CONFIG) : {});
27
+ const config = workerConfig(this.env);
18
28
  const deps = {
19
29
  db: supportDatabase(this.env.DB),
20
30
  ai: this.env.AI,
@@ -24,9 +34,43 @@ var SupportClassifyWorkflow = class extends WorkflowEntrypoint {
24
34
  newId: () => crypto.randomUUID(),
25
35
  now: () => /* @__PURE__ */ new Date()
26
36
  };
27
- await classifiedSteps(step, supportWorkflowRetry, NonRetryableError).do(`classify-${event.payload.messageId}`, async () => {
37
+ if (config.ai.enabled) await classifiedSteps(step, supportWorkflowRetry, NonRetryableError).do(`classify-${event.payload.messageId}`, async () => {
28
38
  await runClassification(deps, event.payload.messageId);
29
39
  });
40
+ const index = supportMessageIndex(config.search);
41
+ if (index) await classifiedSteps(step, supportWorkflowRetry, NonRetryableError).do(`embed-${event.payload.messageId}`, () => runEmbedding({
42
+ db: deps.db,
43
+ ai: this.env.AI,
44
+ store: this.env.SUPPORT_VECTORIZE,
45
+ index,
46
+ now: () => /* @__PURE__ */ new Date(),
47
+ log: createWorkerLogger({ name: `${SUPPORT_CAPABILITY}:embed` })
48
+ }, event.payload.messageId));
49
+ }
50
+ };
51
+ /**
52
+ * Re-embed the semantic message index — `@pithy-sh/vector`'s own reprocess orchestration, over support's
53
+ * `DocumentStore`, one journalled page per step. `pithy vector reprocess --index support-messages` starts it,
54
+ * and so does `pithy support provision` once, to backfill an index it just created.
55
+ */
56
+ var SupportReprocessWorkflow = class extends WorkflowEntrypoint {
57
+ async run(event, step) {
58
+ const params = SupportReprocessParams.parse(event.payload);
59
+ const index = supportMessageIndex(workerConfig(this.env).search);
60
+ if (!index) throw new NonRetryableError("support/reprocess: search.semantic is off in this worker's config, so there is no message index to re-embed");
61
+ const store = this.env.SUPPORT_VECTORIZE;
62
+ if (!store) throw new NonRetryableError(`support/reprocess: the SUPPORT_VECTORIZE binding is absent; run \`${pithyCommandOf("support provision", {}, { runtime: true })}\` to bind the index`);
63
+ return reprocessIndex({
64
+ documents: supportMessageDocuments(supportDatabase(this.env.DB), { store }),
65
+ store,
66
+ ai: this.env.AI,
67
+ index,
68
+ indexName: params.index,
69
+ now: () => /* @__PURE__ */ new Date()
70
+ }, classifiedSteps(step, supportWorkflowRetry, NonRetryableError), {
71
+ ...params.all !== void 0 ? { all: params.all } : {},
72
+ ...params.pageSize !== void 0 ? { pageSize: params.pageSize } : {}
73
+ });
30
74
  }
31
75
  };
32
76
  /**
@@ -36,4 +80,4 @@ var SupportClassifyWorkflow = class extends WorkflowEntrypoint {
36
80
  */
37
81
  var worker_default = workflowHostEntry(SUPPORT_CAPABILITY);
38
82
  //#endregion
39
- export { SupportClassifyWorkflow, worker_default as default };
83
+ export { SupportClassifyWorkflow, SupportReprocessWorkflow, worker_default as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/support",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,12 +40,13 @@
40
40
  "tldts": "^7.4.10"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/auth": "^0.11.0",
44
- "@pithy-sh/core": "^0.13.0",
45
- "@pithy-sh/email": "^0.7.0",
46
- "@pithy-sh/payments": "^0.6.1",
47
- "@pithy-sh/secrets": "^0.6.1",
48
- "@pithy-sh/storage": "^0.6.0",
43
+ "@pithy-sh/auth": "^0.12.1",
44
+ "@pithy-sh/core": "^0.15.0",
45
+ "@pithy-sh/email": "^0.8.1",
46
+ "@pithy-sh/payments": "^0.7.1",
47
+ "@pithy-sh/secrets": "^0.6.3",
48
+ "@pithy-sh/storage": "^0.7.1",
49
+ "@pithy-sh/vector": "^0.5.3",
49
50
  "hono": "^4.13.2",
50
51
  "kysely": "^0.29.0",
51
52
  "zod": "^4.4.0"
@@ -67,6 +68,7 @@
67
68
  "@pithy-sh/secrets": "workspace:*",
68
69
  "@pithy-sh/storage": "workspace:*",
69
70
  "@pithy-sh/tsconfig": "workspace:*",
71
+ "@pithy-sh/vector": "workspace:*",
70
72
  "@types/node": "^22.15.0",
71
73
  "@vitest/coverage-v8": "^4.1.0",
72
74
  "hono": "^4.13.2",
@@ -10,25 +10,43 @@
10
10
  "job": "classify",
11
11
  "className": "SupportClassifyWorkflow",
12
12
  "optional": true
13
+ },
14
+ {
15
+ "type": "workflow",
16
+ "name": "SUPPORT_REPROCESS",
17
+ "job": "reprocess",
18
+ "className": "SupportReprocessWorkflow",
19
+ "optional": true
20
+ },
21
+ { "type": "vectorize", "name": "SUPPORT_VECTORIZE", "optional": true, "remote": true },
22
+ { "type": "ai", "name": "AI", "optional": true, "remote": true },
23
+ {
24
+ "type": "durable_object",
25
+ "name": "PRESENCE_ROOMS",
26
+ "className": "PresenceRoom",
27
+ "classModule": "@pithy-sh/core/src/presence/durableObject"
13
28
  }
14
29
  ],
15
30
  "peerCapabilities": ["secrets"],
16
31
  "optionalCapabilities": ["controlplane", "email", "auth", "payments", "audit", "storage"],
17
32
  "migrationNamespace": "support",
18
- "whenToEnable": "A support inbox that lands mail — and your signed-in users' own reports — in your own D1, classifies both on your own Workers AI binding, and links each sender to the account and purchases your app already knows about. Support is the employee a solo developer cannot hire, and every piece needed to build it was already in the catalog. Mail arrives through Cloudflare Email Routing, is parsed (multipart and attachments included), threaded on In-Reply-To and References so a conversation stays one conversation, and classified three ways at once — category, priority, sentiment — which makes a sortable inbox rather than a labelled one. The taxonomy is federated: eight categories ship, and you add your own with defineSupportCategories. Classification runs on your binding, so the inference lands on your bill and your customers' support mail never leaves your infrastructure. Everything is derived from immutable mail: a wrong classification is recomputed, not repaired, and there is no assignment and no status workflow, because that is a helpdesk product and this is not one. Your signed-in users reach the same inbox without leaving the app: POST /support/feedback opens a thread behind requireAuth(), into the same table, the same classifier, and the same taxonomy — no second inbox, no second console. That channel is where the mail path's hardest problem does not exist, because there is no From: header to spoof: the session is the identity, so the thread records whether its account link was proven by a session or merely matched against an address, and a console can tell the two apart. Replies go out through @pithy-sh/email carrying your domain, your DKIM, and correct threading headers — which only the Worker can set — whichever channel the question arrived on. The admin surface is control-plane only and default-denied; the three in-app routes answer to a user's session and to no scope at all. **Cloudflare Email Routing takes over a zone's MX**, so use a subdomain (support@help.yourdomain.com) and never your apex, or you will move your real mail off your existing provider.",
33
+ "devSecrets": [{ "name": "presence-ticket-key", "devValue": "random" }],
34
+ "whenToEnable": "A support inbox that lands mail — and your signed-in users' own reports — in your own D1, classifies both on your own Workers AI binding, and links each sender to the account and purchases your app already knows about. Support is the employee a solo developer cannot hire, and every piece needed to build it was already in the catalog. Mail arrives through Cloudflare Email Routing, is parsed (multipart and attachments included), threaded on In-Reply-To and References so a conversation stays one conversation, and classified three ways at once — category, priority, sentiment — which makes a sortable inbox rather than a labelled one. The taxonomy is federated: eight categories ship, and you add your own with defineSupportCategories. Classification runs on your binding, so the inference lands on your bill and your customers' support mail never leaves your infrastructure. Everything is derived from immutable mail: a wrong classification is recomputed, not repaired, and there is no status workflow, because that is a helpdesk product and this is not one. What a shared inbox does need, it has: a thread can be assigned to somebody, with every handover kept, and a `support.thread` presence room shows who else has a thread open, and who is replying, before you answer it twice. Your signed-in users reach the same inbox without leaving the app: POST /support/feedback opens a thread behind requireAuth(), into the same table, the same classifier, and the same taxonomy — no second inbox, no second console. That channel is where the mail path's hardest problem does not exist, because there is no From: header to spoof: the session is the identity, so the thread records whether its account link was proven by a session or merely matched against an address, and a console can tell the two apart. Replies go out through @pithy-sh/email carrying your domain, your DKIM, and correct threading headers — which only the Worker can set — whichever channel the question arrived on. The admin surface is control-plane and default-denied, except to your own staff through `support({ staff })`, who may assign a thread and join its presence room from a session; the three in-app feedback routes answer to a user's session and to no scope at all. **Cloudflare Email Routing takes over a zone's MX**, so use a subdomain (support@help.yourdomain.com) and never your apex, or you will move your real mail off your existing provider.",
19
35
  "scaffold": [
20
36
  "Pick the address this inbox will answer on, and put it in `inboundAddresses`. **Use a subdomain, never your apex.** Enabling Email Routing on a zone points its MX at Cloudflare, so `support@yourdomain.com` would move all of your real inbound mail off your current provider. `support@help.yourdomain.com` costs you a subdomain zone and nothing else. Until this is set the inbox is inert and ignores every message.",
21
37
  "Ensure the secrets capability is provisioned (`pithy add secrets` then `pithy secrets provision`) — the R2 credentials attachment URLs are signed with are stored and read through @pithy-sh/secrets.",
22
38
  "Run `pithy support provision --routing-zone <zone-id> --inbound-address <addr> --app-worker <name>`. It creates the `SUPPORT_BUCKET` R2 bucket, deploys the prebuilt classification worker for each environment, and creates the Email Routing rule that delivers mail to your app worker. Idempotent; re-run it freely.",
23
39
  "Add the `DB` (D1) binding to each wrangler.jsonc env. `SUPPORT_BUCKET` (R2) is written for you when your config keeps attachments, the raw MIME copy, or in-app uploads, and left out entirely when it keeps none of them. Add the `SUPPORT_CLASSIFY` Workflow binding your app dispatches to after storing a message — `pithy support provision` writes it.",
24
- "Add the controlplane capability (`pithy add controlplane`) and run `pithy dashboard connect`. Every support route is control-plane only and default-denied: without a registered connection the whole admin surface answers 403, which is the correct posture for an inbox holding other people's private correspondence. Grant the five scopes deliberately — `support:threads:reply` sends mail to real people under your domain and is not the same permission as reading.",
40
+ "Add the controlplane capability (`pithy add controlplane`) and run `pithy dashboard connect`. Every management route is control-plane and default-denied, except to your own staff through `support({ staff })`: without a registered connection the admin surface answers 403, which is the correct posture for an inbox holding other people's private correspondence. Grant the six scopes deliberately — `support:threads:reply` sends mail to real people under your domain and is not the same permission as reading, and `support:threads:assign` decides who answers whom.",
25
41
  "Add the email capability (`pithy add email`) if you want to answer from the dashboard. Without it the inbox still receives, threads, and classifies; the reply route returns `support/reply_failed` naming what is missing.",
26
42
  "Add auth and payments if you have them. Neither is required, and both are reached by guarded dynamic import — with them installed a thread arrives already knowing who the sender is, what they bought, and whether their last renewal failed. **Auth is what makes the in-app channel work at all**: without it `c.var.auth` is null and every `/support/feedback` route denies, which is the correct posture rather than a bug.",
27
43
  "Point your app's feedback form at `POST /support/feedback` and pass the screen, build, and environment as `context`. That set is closed — an undeclared key is refused rather than stored, so this cannot quietly become a telemetry pipe. Review `submission.attachments` before you ship it: the size, count, and content-type allowlist are stated for a direct upload rather than inherited from the mail path, which has no type restriction at all. Set `submission: { enabled: false }` and the routes are not mounted.",
28
44
  "Add your own categories with `defineSupportCategories({ ... })` and your own canned replies with `defineSupportReplies({ ... })`. A category's description is prompt input, read by the model verbatim; a canned reply is a starting point a human picks and edits, never something sent automatically.",
29
45
  "If your feedback screen offers a chooser, send its answer as `declaredCategory`. It lands in its own column beside `category` and is never overwritten by the classifier — one is a claim by the person writing, the other a judgement about them, and an operator triaging an inbox needs to tell them apart. `GET /support/threads` filters on either. A key outside your taxonomy is refused rather than stored, and a category sent with a `threadId` is refused too: a chooser belongs on the form that opens a request.",
30
46
  "`POST /support/feedback` is gated on a session and same-origin and never on a role — a general intake that is role-gated is not one. If your own account model makes some submissions act-on-behalf-of, put that check in your `app` capability's `middleware` over your own `basePath`: every capability's middleware mounts before any capability's routes, so yours runs after auth has resolved the session and before this capability's `requireAuth()`.",
31
- "Leave `search.fts` off unless your inbox has outgrown a `LIKE` scan. Turning it on builds an FTS5 index, and an FTS5 virtual table anywhere in a D1 database makes `wrangler d1 export` refuse to dump the **whole** database — your other tables included."
47
+ "If your own signed-in users work the inbox, name two powers in `support({ staff: { readPower, assignPower } })` and give them to a role in your organization catalog's `unassignable` list that `organization({ ownership })` does not confer, so only an operator can confer them — anybody may found an organization and take its ownership role, so `support()` refuses a staff power there. `readPower` lets a staff member join a thread's presence room; `assignPower` lets them assign it at `POST /support/staff/threads/:id/assign`. Without `staff`, both answer to management clients only.",
48
+ "Search the inbox at `GET /support/threads/search` — keyword by default, ranked; the inbox list takes no `q`. Turn on `search: { semantic: true }` to find a paraphrase as well as an exact word: it embeds every inbound message on your own Workers AI binding, a second inference per message on top of classification, so it is off until you choose it. Then run `pithy support provision`, which creates the Vectorize index, binds it, and backfills it once; `pithy vector reprocess --index support-messages` re-embeds after a model change.",
49
+ "Run `pithy support provision` after adding support or upgrading it: `search.fts` is on by default, and the FTS5 index and its triggers are provisioned rather than migrated. Until then search falls back to a `LIKE` scan and `pithy doctor` names the environment. Back the database up with `pithy backup`, which skips the index, and restore it with `pithy restore`, which rebuilds it — a plain `wrangler d1 export` fails on any database holding an FTS5 table."
32
50
  ],
33
51
  "configOptions": [
34
52
  {
@@ -30,6 +30,15 @@ export const SupportAuditActions = {
30
30
  threadArchived: "support/thread_archived",
31
31
  /** A thread was taken back out of the archive. Recorded separately, because undoing is its own decision. */
32
32
  threadUnarchived: "support/thread_unarchived",
33
+ /**
34
+ * A thread was given to somebody, or handed from one person to another (#816). Audited because who has
35
+ * a customer's conversation decides who answers them, and the thread keeps only the current answer.
36
+ * Metadata: the previous assignee, the new one, and the assigner's note — staff words, never mail.
37
+ * Reassigning the current assignee changes nothing and emits nothing.
38
+ */
39
+ threadAssigned: "support/thread_assigned",
40
+ /** A thread's assignee was cleared. Its own action, because giving a conversation up is its own decision. */
41
+ threadUnassigned: "support/thread_unassigned",
33
42
  /**
34
43
  * A reply went out. Audited because it leaves under the adopter's domain and their DKIM: to the
35
44
  * recipient it is indistinguishable from the founder writing it, so who actually sent it is a
package/src/capability.ts CHANGED
@@ -26,10 +26,19 @@ import { supportAdminRoutes } from "./http/scopes";
26
26
  import { createSupportEmailHandler } from "./inbound/handler";
27
27
  import { senderPeers } from "./link/peers";
28
28
  import type { SenderPeers } from "./link/sender";
29
- import { support_0001_threads } from "./migrations/0001_threads";
29
+ import { SUPPORT_MIGRATIONS } from "./migrations/set";
30
+ import { supportPresence } from "./presence/room";
31
+ import { supportUserPurge } from "./purge/contribution";
30
32
  import { resolveReplies, type SupportReplySnippets } from "./reply/snippets";
31
33
  import { inapplicableAttachmentSecrets, SUPPORT_BUCKET_BINDING, supportSecretsRegistry } from "./secret/registry";
32
34
  import { supportExampleSeed } from "./seeds/example";
35
+ import {
36
+ SUPPORT_AI_BINDING,
37
+ SUPPORT_MESSAGES_INDEX,
38
+ SUPPORT_VECTORIZE_BINDING,
39
+ supportMessageIndex,
40
+ } from "./semantic/messageIndex";
41
+ import { assertStaffComposition } from "./staff/staff";
33
42
  import { PACKAGE_NAME, PACKAGE_VERSION } from "./version.generated";
34
43
  import { supportWorkflows } from "./workflows/specs";
35
44
 
@@ -182,7 +191,8 @@ export interface SupportCapability
182
191
  * The support capability — an inbound support inbox in the adopter's own D1, classified on their own
183
192
  * Workers AI binding.
184
193
  *
185
- * It contributes five tables (and optionally a full-text index) to the app `DB`, an `email()` handler
194
+ * It contributes its tables (and optionally a full-text index, and a semantic message index in Vectorize) to the
195
+ * app `DB`, an `email()` handler
186
196
  * that claims the configured addresses out of the Worker's single inbound entry, the classification
187
197
  * Workflow that handler dispatches to, and a control-plane admin surface for a dashboard to read and
188
198
  * act on.
@@ -213,13 +223,13 @@ export function support(options: SupportOptions = {}): SupportCapability {
213
223
  emit: noopEmit,
214
224
  };
215
225
 
216
- // One migration, unconditionally. The full-text index is **not** here: it is derived from the
226
+ // The whole chain, unconditionally. The full-text index is **not** in it: it is derived from the
217
227
  // messages table and rebuildable at any time, so it is a provisioned resource that
218
228
  // `pithy support provision` creates and drops (see `store/searchIndex.ts`), not schema whose loss
219
229
  // loses data. Keeping it out of the ledger is what makes `search.fts` safe to toggle in either
220
230
  // direction — a config flag must never be able to corrupt a migration set shared with every other
221
231
  // capability in the database.
222
- const migrations: Record<string, Migration> = { "0001_threads": support_0001_threads };
232
+ const migrations: Record<string, Migration> = SUPPORT_MIGRATIONS;
223
233
 
224
234
  const requiredBindings: BindingSpecInput[] = [
225
235
  // The app database every support table lives in.
@@ -239,6 +249,17 @@ export function support(options: SupportOptions = {}): SupportCapability {
239
249
  // The classification Workflow the inbound handler dispatches to, derived from the spec rather
240
250
  // than listed again — one declaration, so a binding rename cannot leave the two disagreeing.
241
251
  ...workflowBindings(supportWorkflows),
252
+ // The semantic message index and the model that embeds a query, **only when `search.semantic` is
253
+ // on**. The index's `index_name` is a provisioning output, so `pithy support provision` writes the entry
254
+ // once the index exists; until then it is absent and the search route skips the semantic backend, naming
255
+ // it, which is why the binding is optional. Neither has a local emulation, so both reach the real resource
256
+ // under `wrangler dev`.
257
+ ...(supportMessageIndex(resolved.search)
258
+ ? [
259
+ { type: "vectorize" as const, name: SUPPORT_VECTORIZE_BINDING, optional: true, remote: true },
260
+ { type: "ai" as const, name: SUPPORT_AI_BINDING, remote: true },
261
+ ]
262
+ : []),
242
263
  ];
243
264
 
244
265
  const capability = defineCapability({
@@ -276,7 +297,12 @@ export function support(options: SupportOptions = {}): SupportCapability {
276
297
  * able to answer is a coherent and useful thing, so a missing email capability degrades one
277
298
  * route instead of refusing to boot.
278
299
  */
300
+ // Support's staff powers, when the adopter names them, so tenancy refuses a catalog that places them on
301
+ // no role. Absent until named: a capability never reserves a power in a catalog that is the adopter's.
302
+ ...(resolved.staff ? { powers: [resolved.staff.readPower, resolved.staff.assignPower] } : {}),
279
303
  compose: ({ capabilities }) => {
304
+ // A staff block no request could be served by safely is refused here, at boot. See `staff/staff.ts`.
305
+ assertStaffComposition(resolved.staff, capabilities);
280
306
  const email = capabilities.find(isEmailCapability);
281
307
  wiring.enqueueEmail = email?.enqueue;
282
308
  // Auth and payments, found rather than imported (#645), and refused here when this Worker's config needs
@@ -288,13 +314,26 @@ export function support(options: SupportOptions = {}): SupportCapability {
288
314
  // for why the list is as short as it is.
289
315
  client: (): SupportClientProjection => clientProjection(resolved, mountPath),
290
316
  adminRoutes: supportAdminRoutes(mountPath),
317
+ // The semantic message index, so `pithy vector reprocess` re-embeds it beside every `vector()` index.
318
+ // Declared only when it exists to re-embed.
319
+ ...(supportMessageIndex(resolved.search)
320
+ ? { vectorIndexes: [{ name: SUPPORT_MESSAGES_INDEX, reprocessJob: "reprocess" }] }
321
+ : {}),
322
+ // Who else has a thread open (#816): one `support.thread` room per thread, on core's presence seam.
323
+ // Declaring it is the whole of the wiring — core adds the room's binding, socket and ticket routes.
324
+ presence: supportPresence(mountPath, wiring),
291
325
  routes: registerSupportRoutes({
292
326
  basePath: mountPath,
293
327
  submission: resolved.submission.enabled,
294
328
  resolveDeps: makeResolveDeps(wiring),
329
+ staff: resolved.staff,
295
330
  }),
296
331
  email: createSupportEmailHandler(wiring),
297
332
  seeds: [supportExampleSeed],
333
+ // A person's threads — linked to their account, or sent from their address and linked to no other — with every
334
+ // message, attachment and raw mail in them: exported on an access request and erased by their purge, as its own
335
+ // step (#814).
336
+ purge: [supportUserPurge("DB", { semantic: supportMessageIndex(resolved.search) !== undefined })],
298
337
  });
299
338
 
300
339
  return Object.assign(capability, {
@@ -324,25 +324,121 @@ export const SupportReplyConfig = z
324
324
  );
325
325
  export type SupportReplyConfig = z.output<typeof SupportReplyConfig>;
326
326
 
327
+ /**
328
+ * The default embedding model for semantic search — the kit's own default (`DEFAULT_EMBEDDING_MODEL` in
329
+ * `@pithy-sh/cloudflare/src/ai/aiManager.ts`), restated rather than imported because a Worker cannot reach
330
+ * the REST client package and should not.
331
+ *
332
+ * **Multilingual, and that is why.** An inbox takes mail in whatever language the customer writes, and an
333
+ * English-only model would embed a Spanish complaint into the wrong neighborhood with no error.
334
+ */
335
+ export const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
336
+
337
+ /**
338
+ * How many components `bge-m3` produces. It is not a Vectorize preset, so the index states its own shape;
339
+ * a model override that produces another width states its `dimensions` beside it.
340
+ */
341
+ export const DEFAULT_EMBEDDING_DIMENSIONS = 1024;
342
+
343
+ /** The `{ model }` form of `search.semantic`: semantic search on, with a model of your choosing. */
344
+ export const SupportSemanticConfig = z
345
+ .object({
346
+ model: z
347
+ .string()
348
+ .min(1)
349
+ .default(DEFAULT_EMBEDDING_MODEL)
350
+ .describe(
351
+ "The Workers AI embedding model the message index is built and queried with. Pinned: change it and `pithy vector reprocess --index support-messages` re-embeds what the old one wrote.",
352
+ ),
353
+ dimensions: z
354
+ .number()
355
+ .int()
356
+ .min(1)
357
+ .max(1536)
358
+ .default(DEFAULT_EMBEDDING_DIMENSIONS)
359
+ .describe(
360
+ "How many components the model produces — 1024 for `bge-m3`, 768 for `bge-base-en-v1.5`. Fixed when the index is created, so a model of another width needs the index dropped and recreated.",
361
+ ),
362
+ })
363
+ .describe("Semantic search with a chosen embedding model.");
364
+ export type SupportSemanticConfig = z.output<typeof SupportSemanticConfig>;
365
+
327
366
  /**
328
367
  * How text search is answered.
329
368
  *
330
- * The one setting here has a cost an adopter must opt into knowingly, which is why it is a setting
331
- * at all rather than a decision this package made for them.
369
+ * FTS5 by default, with the `LIKE` scan kept for a project that wants no virtual table at all. Semantic
370
+ * search is off by default because it costs a second inference per inbound message.
332
371
  */
333
372
  export const SupportSearchConfig = z
334
373
  .object({
374
+ semantic: z
375
+ .union([z.boolean(), SupportSemanticConfig])
376
+ .default(false)
377
+ .describe(
378
+ "Search by meaning as well as by words: `true` for the default model (`@cf/baai/bge-m3`), or `{ model }` for your own. **Off by default, and the reason is cost.** Every inbound message is embedded once, on your own Workers AI binding, in the classification Workflow — a second inference per message on top of classification, which roughly doubles this capability's per-message AI cost. The volume guard bounds messages, not spend. On, `pithy support provision` creates a Vectorize index and backfills it once; off, it drops the index. Vectors carry message ids and nothing else.",
379
+ ),
335
380
  fts: z
336
381
  .boolean()
337
- .default(false)
382
+ .default(true)
338
383
  .describe(
339
- "Build a SQLite FTS5 index over message subjects and bodies. **Off by default, and the reason is not performance.** `wrangler d1 export` refuses to dump any database containing an FTS5 virtual table — it fails outright rather than skipping the table — and the check runs server-side across the whole database before `--table` filtering, so turning this on takes your **entire app database's** export with it. A failed attempt has also been reported to leave the database inaccessible until it clears. If you do not use `wrangler d1 export`, none of that reaches you and FTS5 is the better search. Safe to toggle either way: the index is derived rather than migrated, so `pithy support provision` creates or drops it to match — and until you re-provision, search falls back to the `LIKE` scan rather than failing.",
384
+ "Build a SQLite FTS5 index over message subjects and bodies — a ranked word search instead of an unindexed `LIKE` scan. **On by default.** A plain `wrangler d1 export` of a database holding an FTS5 table fails outright, so `pithy backup` leaves the index and its triggers out and `pithy restore` rebuilds them from `pithy_support_messages`; the bare `wrangler` route has to skip it by hand. The index is derived rather than migrated, so `pithy support provision` creates or drops it to match this flag, and until a project re-provisions, search falls back to the `LIKE` scan and `pithy doctor` names the environment.",
340
385
  ),
341
386
  })
342
- .describe("Text search settings — the `LIKE` scan by default, FTS5 as a deliberate opt-in.");
387
+ .describe(
388
+ "Search settings — FTS5 by default, the `LIKE` scan when it is off or not yet provisioned, and semantic search an opt-in priced in resources you own.",
389
+ );
343
390
  export type SupportSearchConfig = z.output<typeof SupportSearchConfig>;
344
391
 
392
+ /**
393
+ * The semantic model and width this configuration asks for, or `undefined` when semantic search is off.
394
+ *
395
+ * The one reading of the three-shaped flag — `false`, `true`, `{ model }` — so the capability's bindings,
396
+ * the search route, the classification Workflow and provisioning cannot each decide what `true` means.
397
+ */
398
+ export function supportSemantic(search: SupportSearchConfig): SupportSemanticConfig | undefined {
399
+ if (search.semantic === false) return undefined;
400
+ if (search.semantic === true) return { model: DEFAULT_EMBEDDING_MODEL, dimensions: DEFAULT_EMBEDDING_DIMENSIONS };
401
+ return search.semantic;
402
+ }
403
+
345
404
  /** The full support configuration. */
405
+ /**
406
+ * **Who of the app's own signed-in users works the inbox (#816).** Off until named.
407
+ *
408
+ * The inbox holds every customer's correspondence, so a signed-in user reaches it only through a power the
409
+ * adopter names in their own role catalog, held in the organization the request acts for — the same seam
410
+ * `media:manage` and `storage:manage` ask, and the same rule `artifacts({ managePower })` follows: a
411
+ * capability never reserves a power name in a catalog that is the adopter's.
412
+ *
413
+ * **Both powers must sit only on roles no organization can hand itself** — listed in
414
+ * `defineRoles({ unassignable })`, and not the role `organization({ ownership })` confers — and `support()`
415
+ * refuses the composition otherwise. A role is the catalog's, not one organization's, and an organization's
416
+ * own administrator may give any assignable role to their own members — so a support power on an assignable
417
+ * role would let any customer's administrator make themselves staff and read every other customer's mail.
418
+ * The ownership role is unassignable and still self-served: anybody may found an organization, nominate
419
+ * themselves its owner, and accept. On any other unassignable role it is conferred by an operator alone,
420
+ * through the control plane.
421
+ */
422
+ export const SupportStaffConfig = z
423
+ .object({
424
+ readPower: z
425
+ .string()
426
+ .min(1)
427
+ .describe(
428
+ "The power that lets a signed-in user see who else has a thread open — what the `support.thread` presence room's session ticket asks. Your own name, declared in your role catalog on unassignable roles only, and not on the role ownership transfer confers, e.g. `support:read`.",
429
+ ),
430
+ assignPower: z
431
+ .string()
432
+ .min(1)
433
+ .describe(
434
+ "The power that lets a signed-in user assign a thread, to themselves or to somebody else, at `POST {basePath}/staff/threads/:id/assign`. Your own name, declared in your role catalog on unassignable roles only, and not on the role ownership transfer confers, e.g. `support:assign`.",
435
+ ),
436
+ })
437
+ .describe(
438
+ "Who of your app's own signed-in users works the inbox, by the organization powers their role holds. Requires `organization`, and both powers on unassignable roles other than the one ownership transfer confers. Absent, no signed-in user can assign a thread or join a thread's presence room — management clients still can, through the control plane.",
439
+ );
440
+ export type SupportStaffConfig = z.output<typeof SupportStaffConfig>;
441
+
346
442
  export const SupportConfig = z
347
443
  .object({
348
444
  inboundAddresses: z
@@ -373,6 +469,9 @@ export const SupportConfig = z
373
469
  submission: SupportSubmissionConfig.prefault({}).describe("The in-app submission channel."),
374
470
  reply: SupportReplyConfig.prefault({}).describe("Reply settings."),
375
471
  search: SupportSearchConfig.prefault({}).describe("How text search is answered."),
472
+ staff: SupportStaffConfig.optional().describe(
473
+ "Who of your app's signed-in users works the inbox, by organization power. Absent, the staff session routes refuse everyone.",
474
+ ),
376
475
  })
377
476
  .describe(
378
477
  "Configuration for the support capability — which addresses it claims, and how it classifies and bounds them.",