@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
@@ -1,13 +1,13 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
4
+ import type { AdminRouteDeclaration } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
5
5
  import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
6
6
 
7
7
  /**
8
8
  * Support's control-plane scopes, and the admin surface a manifest advertises.
9
9
  *
10
- * **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
10
+ * **Separate from `guards.ts` because a scope name is a client's business**. A management
11
11
  * client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
12
12
  * writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
13
13
  * program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
@@ -15,7 +15,7 @@ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/sc
15
15
  * program has none of. **This module imports types and nothing else, and a gate holds it there**:
16
16
  * `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
17
17
  *
18
- * ## Five scopes, because these are five different blast radii
18
+ * ## Six scopes, because these are six different blast radii
19
19
  *
20
20
  * The temptation is one `support:admin` flag. It is wrong on the merits, the same way payments'
21
21
  * single flag was: **reading** an inbox exposes every customer's private correspondence, while
@@ -35,7 +35,7 @@ export const SUPPORT_THREADS_READ_SCOPE: ControlPlaneScope = "support:threads:re
35
35
  export const SUPPORT_THREADS_ARCHIVE_SCOPE: ControlPlaneScope = "support:threads:archive";
36
36
 
37
37
  /**
38
- * Send mail to a customer under the adopter's domain. The most dangerous of the five and the reason
38
+ * Send mail to a customer under the adopter's domain. The most dangerous of the six and the reason
39
39
  * a single admin flag was never an option.
40
40
  */
41
41
  export const SUPPORT_THREADS_REPLY_SCOPE: ControlPlaneScope = "support:threads:reply";
@@ -46,6 +46,12 @@ export const SUPPORT_THREADS_RECLASSIFY_SCOPE: ControlPlaneScope = "support:thre
46
46
  /** Set a viewer's own read/snooze flags. Private, uncoordinated, and the least dangerous thing here. */
47
47
  export const SUPPORT_THREADS_FLAG_SCOPE: ControlPlaneScope = "support:threads:flag";
48
48
 
49
+ /**
50
+ * Say who has a thread, or that nobody does. Shared state like archive, and its own scope like
51
+ * archive, because deciding who answers a customer is not the same permission as marking them done.
52
+ */
53
+ export const SUPPORT_THREADS_ASSIGN_SCOPE: ControlPlaneScope = "support:threads:assign";
54
+
49
55
  /**
50
56
  * Every control-plane scope support defines — what `pithy dashboard connect` offers for this
51
57
  * capability, and the list a manifest or a doc quotes rather than re-typing.
@@ -56,6 +62,7 @@ export const SUPPORT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
56
62
  SUPPORT_THREADS_REPLY_SCOPE,
57
63
  SUPPORT_THREADS_RECLASSIFY_SCOPE,
58
64
  SUPPORT_THREADS_FLAG_SCOPE,
65
+ SUPPORT_THREADS_ASSIGN_SCOPE,
59
66
  ];
60
67
 
61
68
  /**
@@ -69,48 +76,78 @@ export const SUPPORT_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
69
76
  * The summaries say what the operation is *for*. A client renders these next to a button somebody is
70
77
  * about to press on a real customer's conversation.
71
78
  */
72
- export function supportAdminRoutes(basePath: string): AdminRoute[] {
79
+ export function supportAdminRoutes(basePath: string): AdminRouteDeclaration[] {
73
80
  return [
74
81
  {
75
82
  method: "GET",
83
+ operation: "threads.list",
76
84
  path: `${basePath}/threads`,
77
85
  scope: SUPPORT_THREADS_READ_SCOPE,
78
- summary: "List the inbox, newest first, filtered by category, priority, and archived, searched by text.",
86
+ kind: "reads",
87
+ summary: "List the inbox, newest first, filtered by category, priority, assignee, and archived.",
88
+ },
89
+ {
90
+ method: "GET",
91
+ operation: "threads.search",
92
+ path: `${basePath}/threads/search`,
93
+ scope: SUPPORT_THREADS_READ_SCOPE,
94
+ kind: "reads",
95
+ summary: "Search the inbox by words and by meaning, ranked as one, with the same filters as the list.",
79
96
  },
80
97
  {
81
98
  method: "GET",
99
+ operation: "threads.read",
82
100
  path: `${basePath}/threads/:id`,
83
101
  scope: SUPPORT_THREADS_READ_SCOPE,
102
+ kind: "reads",
84
103
  summary: "Read one conversation, with the sender's linked account and what they bought.",
85
104
  },
86
105
  {
87
106
  method: "POST",
107
+ operation: "threads.archive",
88
108
  path: `${basePath}/threads/:id/archive`,
89
109
  scope: SUPPORT_THREADS_ARCHIVE_SCOPE,
110
+ kind: "writes",
90
111
  summary: "Mark a conversation done, or reopen one.",
91
112
  },
92
113
  {
93
114
  method: "POST",
115
+ operation: "threads.assign",
116
+ path: `${basePath}/threads/:id/assign`,
117
+ scope: SUPPORT_THREADS_ASSIGN_SCOPE,
118
+ kind: "writes",
119
+ summary: "Give a conversation to somebody, hand it on, or give it up.",
120
+ },
121
+ {
122
+ method: "POST",
123
+ operation: "threads.reply",
94
124
  path: `${basePath}/threads/:id/reply`,
95
125
  scope: SUPPORT_THREADS_REPLY_SCOPE,
126
+ kind: "sends",
96
127
  summary: "Answer the customer, threaded so their mail client keeps one conversation.",
97
128
  },
98
129
  {
99
130
  method: "POST",
131
+ operation: "threads.reclassify",
100
132
  path: `${basePath}/threads/:id/reclassify`,
101
133
  scope: SUPPORT_THREADS_RECLASSIFY_SCOPE,
134
+ kind: "writes",
102
135
  summary: "Re-run the classifier over this conversation's latest inbound message.",
103
136
  },
104
137
  {
105
138
  method: "POST",
139
+ operation: "threads.flags",
106
140
  path: `${basePath}/threads/:id/flags`,
107
141
  scope: SUPPORT_THREADS_FLAG_SCOPE,
142
+ kind: "writes",
108
143
  summary: "Set your own read and snooze state for a conversation. Private to you.",
109
144
  },
110
145
  {
111
146
  method: "GET",
147
+ operation: "replies.list",
112
148
  path: `${basePath}/replies`,
113
149
  scope: SUPPORT_THREADS_READ_SCOPE,
150
+ kind: "reads",
114
151
  summary: "The canned reply catalog — starting points to pick from, ordered for a thread's category.",
115
152
  },
116
153
  ];
package/src/http/views.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import type { SupportAssignment } from "../data/assignment";
4
5
  import type { SupportMessage } from "../data/message";
5
6
  import type { SupportThread } from "../data/thread";
6
7
  import type { SenderContext } from "../link/sender";
8
+ import type { SearchOutcome, SearchResult } from "../store/hybrid";
7
9
  import type { ListedThread } from "../store/threads";
8
10
  import type {
9
11
  SenderContextView,
12
+ SupportAssignmentView,
10
13
  SupportAttachmentView,
11
14
  SupportListedThreadView,
12
15
  SupportMessageView,
13
16
  SupportMyMessageView,
14
17
  SupportMyThreadView,
18
+ SupportSearchResponse,
19
+ SupportSearchResultView,
15
20
  SupportThreadView,
16
21
  } from "./responses";
17
22
 
@@ -60,6 +65,10 @@ export function threadView(thread: SupportThread): SupportThreadView {
60
65
  archived: thread.archived,
61
66
  archivedAt: iso(thread.archivedAt),
62
67
  archivedBy: thread.archivedBy ?? null,
68
+ assigneeRef: thread.assigneeRef ?? null,
69
+ assigneeName: thread.assigneeName ?? null,
70
+ assignedAt: iso(thread.assignedAt),
71
+ assignedBy: thread.assignedBy ?? null,
63
72
  messageCount: thread.messageCount,
64
73
  firstMessageAt: thread.firstMessageAt.toISOString(),
65
74
  lastMessageAt: thread.lastMessageAt.toISOString(),
@@ -68,11 +77,46 @@ export function threadView(thread: SupportThread): SupportThreadView {
68
77
  };
69
78
  }
70
79
 
80
+ /** Project one change of a thread's assignee. The thread id is the route's, so it is not repeated. */
81
+ export function assignmentView(assignment: SupportAssignment): SupportAssignmentView {
82
+ return {
83
+ id: assignment.id,
84
+ assigneeRef: assignment.assigneeRef ?? null,
85
+ assigneeName: assignment.assigneeName ?? null,
86
+ previousRef: assignment.previousRef ?? null,
87
+ previousName: assignment.previousName ?? null,
88
+ actorType: assignment.actorType,
89
+ assignedBy: assignment.assignedBy,
90
+ note: assignment.note ?? null,
91
+ createdAt: assignment.createdAt.toISOString(),
92
+ };
93
+ }
94
+
71
95
  /** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
72
96
  export function listedThreadView(thread: ListedThread): SupportListedThreadView {
73
97
  return { ...threadView(thread), read: thread.read, snoozedUntil: iso(thread.snoozedUntil) };
74
98
  }
75
99
 
100
+ /** Project one search result: the thread as this viewer sees it, and its best message's raw scores. */
101
+ export function searchResultView(result: SearchResult): SupportSearchResultView {
102
+ return {
103
+ thread: listedThreadView(result.thread),
104
+ messageId: result.messageId,
105
+ ...(result.bm25 !== undefined ? { bm25: result.bm25 } : {}),
106
+ ...(result.similarity !== undefined ? { similarity: result.similarity } : {}),
107
+ };
108
+ }
109
+
110
+ /** Project a whole search. */
111
+ export function searchView(outcome: SearchOutcome): SupportSearchResponse {
112
+ return {
113
+ mode: outcome.mode,
114
+ backends: { ran: [...outcome.ran], skipped: outcome.skipped.map((skip) => ({ ...skip })) },
115
+ count: outcome.results.length,
116
+ results: outcome.results.map(searchResultView),
117
+ };
118
+ }
119
+
76
120
  /**
77
121
  * Project one message.
78
122
  *
@@ -7,7 +7,7 @@ import { chunkRowsByBoundParameters } from "@pithy-sh/core/src/data/boundParamet
7
7
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
8
8
  import { attachmentKey, putAttachment, rawMessageKey, sha256Hex } from "../attachment/store";
9
9
  import { SupportAuditActions } from "../audit/actions";
10
- import type { SupportConfig } from "../config/config";
10
+ import { type SupportConfig, supportSemantic } from "../config/config";
11
11
  import { SupportAttachment } from "../data/attachment";
12
12
  import { SupportMessage } from "../data/message";
13
13
  import {
@@ -520,7 +520,10 @@ export async function ingestInbound(deps: IngestDeps, input: IngestInput): Promi
520
520
  // 9. Classification, last and allowed to fail. An out-of-office is not worth an inference, and an
521
521
  // adopter who turned classification off should pay nothing for it.
522
522
  const classifying = deps.config.ai.enabled && !parsed.autoSubmitted;
523
- if (classifying) {
523
+ // The same Workflow embeds the message when semantic search is on, so it runs for either reason —
524
+ // an inbox with classification off and semantic search on still wants its mail findable by meaning.
525
+ const embedding = supportSemantic(deps.config.search) !== undefined && !parsed.autoSubmitted;
526
+ if (classifying || embedding) {
524
527
  try {
525
528
  await deps.dispatchClassify(messageId);
526
529
  } catch (error) {
package/src/index.ts CHANGED
@@ -25,7 +25,9 @@ export {
25
25
  SupportGuardConfig,
26
26
  SupportReplyConfig,
27
27
  SupportSearchConfig,
28
+ SupportStaffConfig,
28
29
  } from "./config/config";
30
+ export { SupportAssignment, SupportAssignmentActorType, UNASSIGNED } from "./data/assignment";
29
31
  export { SupportAttachment } from "./data/attachment";
30
32
  export {
31
33
  DEFAULT_SUPPORT_CATEGORIES,
@@ -37,6 +39,7 @@ export { SupportMessageDirection, SupportPriority, SupportSentiment, UNCATEGORIZ
37
39
  export { SupportThreadFlag } from "./data/flag";
38
40
  export { SupportMessage } from "./data/message";
39
41
  export {
42
+ SUPPORT_ASSIGNMENTS_TABLE,
40
43
  SUPPORT_ATTACHMENTS_TABLE,
41
44
  SUPPORT_CLASSIFICATIONS_TABLE,
42
45
  SUPPORT_FLAGS_TABLE,
@@ -50,11 +53,13 @@ export { SupportThread } from "./data/thread";
50
53
  export {
51
54
  SUPPORT_CONTROL_PLANE_SCOPES,
52
55
  SUPPORT_THREADS_ARCHIVE_SCOPE,
56
+ SUPPORT_THREADS_ASSIGN_SCOPE,
53
57
  SUPPORT_THREADS_FLAG_SCOPE,
54
58
  SUPPORT_THREADS_READ_SCOPE,
55
59
  SUPPORT_THREADS_RECLASSIFY_SCOPE,
56
60
  SUPPORT_THREADS_REPLY_SCOPE,
57
61
  } from "./http/scopes";
62
+ export { SUPPORT_THREAD_ACTIVITIES, SUPPORT_THREAD_ROOM } from "./presence/room";
58
63
  export {
59
64
  DEFAULT_SUPPORT_REPLIES,
60
65
  defineSupportReplies,
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Who has a thread (#816): the current assignee on `pithy_support_threads`, and every change of it in a
9
+ * new append-only `pithy_support_assignments`.
10
+ *
11
+ * **A `0002`, appended, never folded into `0001`.** `@pithy-sh/support` has shipped, so its chain is
12
+ * history.
13
+ *
14
+ * **Four nullable columns and no backfill.** Every thread that exists when this runs has no assignee, and
15
+ * null is exactly that.
16
+ *
17
+ * **One index for the filter.** The inbox lists `archived`, newest first, and `assignee=<ref>` narrows it;
18
+ * `(assigneeRef, archived, lastMessageAt, id)` serves that page and its cursor the way
19
+ * `pithySupportThreadsArchivedIdx` serves the unfiltered one. `assignee=none` reads the unfiltered index
20
+ * and is a residual predicate on it.
21
+ */
22
+ export const support_0002_assignment: Migration = {
23
+ async up(db: Kysely<unknown>): Promise<void> {
24
+ await db.schema.alterTable("pithySupportThreads").addColumn("assigneeRef", "text").execute();
25
+ await db.schema.alterTable("pithySupportThreads").addColumn("assigneeName", "text").execute();
26
+ await db.schema.alterTable("pithySupportThreads").addColumn("assignedAt", "integer").execute();
27
+ await db.schema.alterTable("pithySupportThreads").addColumn("assignedBy", "text").execute();
28
+ await db.schema
29
+ .createIndex("pithySupportThreadsAssigneeIdx")
30
+ .on("pithySupportThreads")
31
+ .columns(["assigneeRef", "archived", "lastMessageAt", "id"])
32
+ .execute();
33
+
34
+ await db.schema
35
+ .createTable("pithySupportAssignments")
36
+ .addColumn("id", "text", (c) => c.primaryKey())
37
+ .addColumn("threadId", "text", (c) => c.notNull())
38
+ .addColumn("assigneeRef", "text")
39
+ .addColumn("assigneeName", "text")
40
+ .addColumn("previousRef", "text")
41
+ .addColumn("previousName", "text")
42
+ .addColumn("actorType", "text", (c) => c.notNull())
43
+ .addColumn("assignedBy", "text", (c) => c.notNull())
44
+ .addColumn("note", "text")
45
+ .addColumn("createdAt", "integer", (c) => c.notNull())
46
+ .execute();
47
+ // A thread's history, oldest first — the only way this table is read.
48
+ await db.schema
49
+ .createIndex("pithySupportAssignmentsThreadIdx")
50
+ .on("pithySupportAssignments")
51
+ .columns(["threadId", "createdAt"])
52
+ .execute();
53
+ },
54
+ async down(db: Kysely<unknown>): Promise<void> {
55
+ // The exact inverse, in reverse. The index goes before its columns, because SQLite refuses to drop a
56
+ // column an index still names.
57
+ await db.schema.dropIndex("pithySupportAssignmentsThreadIdx").execute();
58
+ await db.schema.dropTable("pithySupportAssignments").execute();
59
+ await db.schema.dropIndex("pithySupportThreadsAssigneeIdx").execute();
60
+ await db.schema.alterTable("pithySupportThreads").dropColumn("assignedBy").execute();
61
+ await db.schema.alterTable("pithySupportThreads").dropColumn("assignedAt").execute();
62
+ await db.schema.alterTable("pithySupportThreads").dropColumn("assigneeName").execute();
63
+ await db.schema.alterTable("pithySupportThreads").dropColumn("assigneeRef").execute();
64
+ },
65
+ };
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * The semantic index's model stamps: one row per embedded message in `pithy_support_embeddings`.
9
+ *
10
+ * **Appended, and the messages table is not touched**: a released migration is never edited, and the stamp
11
+ * describes a derived index, not the mail. The table exists whether or not `search.semantic` is on, because the
12
+ * migration set must not vary with a config flag (`store/searchIndex.ts` says why); an empty table costs nothing.
13
+ *
14
+ * No index beyond the primary key. A reprocess page reads messages in id order and joins this on `messageId`,
15
+ * which the key serves.
16
+ */
17
+ export const support_0003_embeddings: Migration = {
18
+ async up(db: Kysely<unknown>): Promise<void> {
19
+ await db.schema
20
+ .createTable("pithySupportEmbeddings")
21
+ .addColumn("messageId", "text", (c) => c.primaryKey())
22
+ .addColumn("model", "text", (c) => c.notNull())
23
+ .addColumn("embeddedAt", "integer", (c) => c.notNull())
24
+ .execute();
25
+ },
26
+ async down(db: Kysely<unknown>): Promise<void> {
27
+ await db.schema.dropTable("pithySupportEmbeddings").execute();
28
+ },
29
+ };
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Migration } from "kysely/migration";
5
+ import { support_0001_threads } from "./0001_threads";
6
+ import { support_0002_assignment } from "./0002_assignment";
7
+ import { support_0003_embeddings } from "./0003_embeddings";
8
+
9
+ /**
10
+ * Every support migration, in order — the set the capability contributes and the one every test that
11
+ * needs today's schema runs. Append-only: a released chain is history.
12
+ *
13
+ * The full-text index is **not** here: it is a provisioned resource `pithy support provision` creates and
14
+ * drops (`store/searchIndex.ts`), so a config flag can never corrupt a migration set shared with every
15
+ * other capability in the database.
16
+ */
17
+ export const SUPPORT_MIGRATIONS: Record<string, Migration> = {
18
+ "0001_threads": support_0001_threads,
19
+ "0002_assignment": support_0002_assignment,
20
+ "0003_embeddings": support_0003_embeddings,
21
+ };
@@ -0,0 +1,94 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import {
6
+ PRESENCE_TICKET_KEY_SECRET,
7
+ type PresenceDeclaration,
8
+ type PresenceSessionGate,
9
+ } from "@pithy-sh/core/src/presence/declaration";
10
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
11
+ import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
12
+ import type { Context } from "hono";
13
+ import type { SupportWiring } from "../capability";
14
+ import type { SupportStaffConfig } from "../config/config";
15
+ import { SUPPORT_THREADS_READ_SCOPE } from "../http/scopes";
16
+ import { resolveSubmitterAccount } from "../link/sender";
17
+ import { supportPresenceRegistry } from "../secret/registry";
18
+ import { staffMay } from "../staff/staff";
19
+
20
+ /**
21
+ * Who else has a support thread open (#816) — support's one room kind on core's presence seam (#815).
22
+ *
23
+ * Anyone who may read a thread may see who else has it open, so the room joins with the scope that already
24
+ * reads it, `support:threads:read`, and adds none of its own. A management client mints its ticket at
25
+ * `POST /control-plane/presence/tickets`; a signed-in user of the app mints one at
26
+ * `POST {basePath}/presence/tickets` when `support({ staff })` names a read power their role holds. The room
27
+ * holds nothing but who is here and writes nothing anywhere.
28
+ */
29
+
30
+ /** The room kind: one room per thread, keyed by the thread's id. */
31
+ export const SUPPORT_THREAD_ROOM = "support.thread";
32
+
33
+ /**
34
+ * What a participant may announce. `viewing` first, because the first is what a participant joins as;
35
+ * `replying` is announced by the client when its reply form opens, and cleared when it closes.
36
+ */
37
+ export const SUPPORT_THREAD_ACTIVITIES = ["viewing", "replying"] as const;
38
+
39
+ /** The account name a staff participant joins under, or null when it cannot be read. */
40
+ export type StaffName = (c: Context<PithyHonoEnv>, userId: string) => Promise<{ name?: string } | null>;
41
+
42
+ /**
43
+ * The session gate: the read power in the organization this request acts for, the same question the staff
44
+ * routes ask. Admits under the account's own name; with none to read, core falls back to the user id.
45
+ */
46
+ export function staffPresenceGate(staff: SupportStaffConfig, nameOf: StaffName): PresenceSessionGate {
47
+ return async (c) => {
48
+ const userId = c.var.auth?.userId;
49
+ if (!userId) return null;
50
+ if (!staffMay(await c.var.actingOrganization(), staff, "readPower")) return null;
51
+ const account = await nameOf(c, userId);
52
+ return account?.name ? { name: account.name } : {};
53
+ };
54
+ }
55
+
56
+ /**
57
+ * The ticket signing keys, read through `@pithy-sh/secrets` per request — the current version signs and
58
+ * every version still honored verifies, so a rotation never strands a socket mid-handshake.
59
+ */
60
+ async function ticketKeys(env: Record<string, unknown>): Promise<string[]> {
61
+ const secrets = await sharedSecretsStore(env as unknown as SecretsStoreEnv, supportPresenceRegistry);
62
+ const { currentVersion, versions } = secrets.getVersions(PRESENCE_TICKET_KEY_SECRET);
63
+ const current = versions[currentVersion];
64
+ return [
65
+ ...(current === undefined ? [] : [current]),
66
+ ...Object.entries(versions)
67
+ .filter(([version]) => version !== currentVersion)
68
+ .map(([, value]) => value),
69
+ ];
70
+ }
71
+
72
+ /** Support's presence declaration, built from its resolved mount and staff config. */
73
+ export function supportPresence(basePath: string, wiring: SupportWiring): PresenceDeclaration {
74
+ const staff = wiring.config.staff;
75
+ return {
76
+ basePath,
77
+ ticketKey: ticketKeys,
78
+ rooms: [
79
+ {
80
+ kind: SUPPORT_THREAD_ROOM,
81
+ scope: SUPPORT_THREADS_READ_SCOPE,
82
+ activities: SUPPORT_THREAD_ACTIVITIES,
83
+ ...(staff
84
+ ? {
85
+ session: staffPresenceGate(staff, async (c, userId) => {
86
+ const d1 = (c.env as unknown as { DB?: Parameters<typeof resolveSubmitterAccount>[0] }).DB;
87
+ return d1 ? resolveSubmitterAccount(d1, userId, wiring.peers) : null;
88
+ }),
89
+ }
90
+ : {}),
91
+ },
92
+ ],
93
+ };
94
+ }
@@ -113,6 +113,24 @@ export interface SupportProvisioner {
113
113
  * and backfills the gap, and says which of the three it did.
114
114
  */
115
115
  ensureSearchIndex(env: ManagedEnvironment): Promise<{ created: boolean; dropped: boolean; repaired: boolean }>;
116
+ /**
117
+ * The semantic message index in this environment, **before** the worker that binds it is deployed:
118
+ * created when `search.semantic` is on and it does not exist yet, untouched otherwise. Before, because a
119
+ * deploy that binds an index nobody created fails. Idempotent — an index that exists is reused as-is, and
120
+ * one with a different shape is refused rather than silently queried in the wrong space.
121
+ */
122
+ ensureSemanticIndex(env: ManagedEnvironment): Promise<{ indexName: string; created: boolean }>;
123
+ /**
124
+ * The semantic message index in this environment, **after** the worker is deployed:
125
+ *
126
+ * - On, and holding nothing yet — `created` by this run, or holding no stamp while inbound mail exists, which
127
+ * is what a run that failed after creating it leaves: backfill it once, by starting the reprocess Workflow
128
+ * over every inbound message. An index holding any stamp is never re-embedded here — that is
129
+ * `pithy vector reprocess`.
130
+ * - Off: drop the index if it exists, and the model stamps with it, since every stamp now names a vector
131
+ * that is gone. After the deploy, because the worker that bound it no longer does.
132
+ */
133
+ settleSemanticIndex(env: ManagedEnvironment, created: boolean): Promise<{ dropped: boolean; backfilled: boolean }>;
116
134
  /**
117
135
  * Ensure the inbound Email Routing rule that delivers the support address to the app worker.
118
136
  * Idempotent, keyed on the rule name. Returns `skipped: true` when no routing config was supplied.
@@ -128,6 +146,14 @@ export interface SupportProvisionResult {
128
146
  environments: ManagedEnvironment[];
129
147
  /** What the full-text index did, per environment — created, dropped, repaired, or already correct. */
130
148
  search: Array<{ env: ManagedEnvironment; created: boolean; dropped: boolean; repaired: boolean }>;
149
+ /** What the semantic message index did, per environment — created and backfilled, dropped, or unchanged. */
150
+ semantic: Array<{
151
+ env: ManagedEnvironment;
152
+ indexName: string;
153
+ created: boolean;
154
+ dropped: boolean;
155
+ backfilled: boolean;
156
+ }>;
131
157
  /** Whether the inbound routing rule was created, already present, or skipped. */
132
158
  routing: { created: boolean; skipped: boolean };
133
159
  }
@@ -155,19 +181,25 @@ export async function provisionSupport(
155
181
  await provisioner.preflight();
156
182
  const bucket = await provisioner.ensureBucket();
157
183
  const search: SupportProvisionResult["search"] = [];
184
+ const semantic: SupportProvisionResult["semantic"] = [];
158
185
  const deployed = managedEnvironments(environments);
159
186
  for (const env of deployed) {
187
+ // The message index first: the worker about to be deployed binds it, and a binding to an index nobody
188
+ // created fails the deploy.
189
+ const index = await provisioner.ensureSemanticIndex(env);
160
190
  await provisioner.deployWorker(env);
161
191
  // Per environment, because each has its own app database — and after the worker, so a database
162
192
  // that gains the index always has something able to write to it.
163
193
  search.push({ env, ...(await provisioner.ensureSearchIndex(env)) });
194
+ // The backfill runs on the worker just deployed, and a drop waits until no worker binds the index.
195
+ semantic.push({ env, ...index, ...(await provisioner.settleSemanticIndex(env, index.created)) });
164
196
  }
165
197
  // Only when at least one classification host is up. The rule is what starts delivering real customer
166
198
  // mail, so making it over a run that deployed nothing is the same window the ordering above closes,
167
199
  // widened to the whole project.
168
200
  const routing =
169
201
  deployed.length > 0 ? await provisioner.ensureRoutingRule() : { created: false, skipped: true as const };
170
- return { bucket, environments: deployed, routing, search };
202
+ return { bucket, environments: deployed, routing, search, semantic };
171
203
  }
172
204
 
173
205
  /** The teardown seam — the inverse of {@link SupportProvisioner}. Every step idempotent. */
@@ -5,6 +5,7 @@ import type { FeatureIdentity } from "@pithy-sh/core/src/naming/feature";
5
5
  import { hostWorkflowsFor, resolveWorkflowHost, type WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
6
6
  import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
7
7
  import type { SupportConfig } from "../config/config";
8
+ import { supportMessageIndex, supportMessageIndexName } from "../semantic/messageIndex";
8
9
  import { SUPPORT_CAPABILITY, supportWorkflowRegistry } from "../workflows/specs";
9
10
 
10
11
  /**
@@ -60,21 +61,31 @@ export function resolveSupportConfig(
60
61
  ): WorkflowHostTemplate {
61
62
  const feature = params.feature;
62
63
  const { project, env } = params;
63
- return resolveWorkflowHost(template, {
64
- project,
65
- capability: SUPPORT_CAPABILITY,
66
- env,
67
- ...(feature ? { feature } : {}),
68
- databaseIds: { DB: params.appDatabaseId },
69
- remoteBindings: ["AI"],
70
- vars: { SUPPORT_CONFIG: JSON.stringify(params.supportConfig) },
71
- // The classification Workflow, derived from support's own specs. A Workflow name is
72
- // account-scoped, so it has to carry the project, and only the registry knows the job.
73
- workflows: hostWorkflowsFor(supportWorkflowRegistry, {
64
+ // The semantic message index, only when `search.semantic` is on. The template carries no `vectorize`
65
+ // entry because most inboxes have none; one is added here, named the way provisioning created it.
66
+ const index = supportMessageIndex(params.supportConfig.search);
67
+ const indexName = index ? supportMessageIndexName(project, env, feature) : undefined;
68
+ return resolveWorkflowHost(
69
+ index && indexName
70
+ ? { ...template, vectorize: [{ binding: index.binding, index_name: indexName, remote: true }] }
71
+ : template,
72
+ {
74
73
  project,
75
74
  capability: SUPPORT_CAPABILITY,
76
75
  env,
77
76
  ...(feature ? { feature } : {}),
78
- }).workflows,
79
- });
77
+ databaseIds: { DB: params.appDatabaseId },
78
+ ...(index && indexName ? { vectorizeIndexNames: { [index.binding]: indexName } } : {}),
79
+ remoteBindings: index ? ["AI", index.binding] : ["AI"],
80
+ vars: { SUPPORT_CONFIG: JSON.stringify(params.supportConfig) },
81
+ // The classification Workflow, derived from support's own specs. A Workflow name is
82
+ // account-scoped, so it has to carry the project, and only the registry knows the job.
83
+ workflows: hostWorkflowsFor(supportWorkflowRegistry, {
84
+ project,
85
+ capability: SUPPORT_CAPABILITY,
86
+ env,
87
+ ...(feature ? { feature } : {}),
88
+ }).workflows,
89
+ },
90
+ );
80
91
  }