mojulo 0.0.0 → 0.1.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 (121) hide show
  1. package/README.md +54 -4
  2. package/lib/audit-logger-new.js +11 -0
  3. package/lib/auth/gate.js +25 -0
  4. package/lib/auth/service.js +17 -0
  5. package/lib/auth/session.js +63 -0
  6. package/lib/builder/chat-processor.js +607 -0
  7. package/lib/builder/composer-bridge.js +82 -0
  8. package/lib/builder/evaluator.js +159 -0
  9. package/lib/builder/executor.js +252 -0
  10. package/lib/builder/index.js +48 -0
  11. package/lib/builder/session.js +248 -0
  12. package/lib/builder/system-prompt.js +422 -0
  13. package/lib/builder/tone-presets.js +75 -0
  14. package/lib/builder/tool-executors.js +1527 -0
  15. package/lib/builder/tools.js +338 -0
  16. package/lib/builder/validators.js +239 -0
  17. package/lib/composer/composer.js +225 -0
  18. package/lib/composer/index.js +40 -0
  19. package/lib/composer/protocols/00_base.txt +19 -0
  20. package/lib/composer/protocols/01_knowledge.txt +9 -0
  21. package/lib/composer/protocols/02_form-gathering.txt +32 -0
  22. package/lib/composer/protocols/03_appointments.txt +16 -0
  23. package/lib/composer/protocols/04_triage.txt +15 -0
  24. package/lib/composer/protocols/05_optical-read.txt +22 -0
  25. package/lib/composer/response-builder.js +98 -0
  26. package/lib/config-builder.js +650 -0
  27. package/lib/db/ids.js +10 -0
  28. package/lib/db/index.js +179 -0
  29. package/lib/db/repositories/apiKeys.js +72 -0
  30. package/lib/db/repositories/auditLogs.js +12 -0
  31. package/lib/db/repositories/botSpaces.js +12 -0
  32. package/lib/db/repositories/builderSessions.js +312 -0
  33. package/lib/db/repositories/deploymentEvents.js +12 -0
  34. package/lib/db/repositories/deployments.js +385 -0
  35. package/lib/db/repositories/documents.js +68 -0
  36. package/lib/db/repositories/mcpJobs.js +84 -0
  37. package/lib/deployers/bot-fleet.js +110 -0
  38. package/lib/deployers/bot-proxy.js +72 -0
  39. package/lib/deployers/build.js +89 -0
  40. package/lib/deployers/cloud-deploy.js +310 -0
  41. package/lib/deployers/docker.js +439 -0
  42. package/lib/deployers/fly.js +432 -0
  43. package/lib/deployers/index.js +38 -0
  44. package/lib/deployment-auth.js +36 -0
  45. package/lib/document-parser.js +171 -0
  46. package/lib/embedder/chunker.js +93 -0
  47. package/lib/embedder/local.js +101 -0
  48. package/lib/embedder/preview-rag.js +93 -0
  49. package/lib/envelope-schema.js +54 -0
  50. package/lib/fleet/scoped-sql.js +342 -0
  51. package/lib/form-schema-config/base.js +135 -0
  52. package/lib/form-schema-config/index.js +286 -0
  53. package/lib/form-schema-config/locales/af-ZA.js +153 -0
  54. package/lib/form-schema-config/locales/ar-AE.js +142 -0
  55. package/lib/form-schema-config/locales/ar-SA.js +164 -0
  56. package/lib/form-schema-config/locales/de-DE.js +152 -0
  57. package/lib/form-schema-config/locales/en-AU.js +161 -0
  58. package/lib/form-schema-config/locales/en-CA.js +115 -0
  59. package/lib/form-schema-config/locales/en-GB.js +132 -0
  60. package/lib/form-schema-config/locales/en-IN.js +219 -0
  61. package/lib/form-schema-config/locales/en-MY.js +171 -0
  62. package/lib/form-schema-config/locales/en-NG.js +198 -0
  63. package/lib/form-schema-config/locales/en-PH.js +186 -0
  64. package/lib/form-schema-config/locales/en-SG.js +153 -0
  65. package/lib/form-schema-config/locales/en-US.js +138 -0
  66. package/lib/form-schema-config/locales/es-ES.js +171 -0
  67. package/lib/form-schema-config/locales/es-MX.js +193 -0
  68. package/lib/form-schema-config/locales/fr-CA.js +138 -0
  69. package/lib/form-schema-config/locales/fr-FR.js +155 -0
  70. package/lib/form-schema-config/locales/hi-IN.js +219 -0
  71. package/lib/form-schema-config/locales/it-IT.js +157 -0
  72. package/lib/form-schema-config/locales/ja-JP.js +169 -0
  73. package/lib/form-schema-config/locales/ko-KR.js +140 -0
  74. package/lib/form-schema-config/locales/nl-NL.js +149 -0
  75. package/lib/form-schema-config/locales/pt-BR.js +168 -0
  76. package/lib/form-schema-config/locales/zh-CN.js +172 -0
  77. package/lib/form-schema-config/locales/zh-HK.js +142 -0
  78. package/lib/form-structure-schema.js +191 -0
  79. package/lib/llm-providers.js +828 -0
  80. package/lib/markdown.js +197 -0
  81. package/lib/mcp/catalysts/appointment-to-calendar.md +84 -0
  82. package/lib/mcp/catalysts/conversations-to-channel-digest.md +104 -0
  83. package/lib/mcp/catalysts/document-extract-to-store.md +92 -0
  84. package/lib/mcp/catalysts/knowledge-gap-miner.md +96 -0
  85. package/lib/mcp/catalysts/loader.js +144 -0
  86. package/lib/mcp/catalysts/qualify-lead-to-crm.md +83 -0
  87. package/lib/mcp/catalysts/scan-conversations-for-signal.md +92 -0
  88. package/lib/mcp/catalysts/submission-to-ticket.md +83 -0
  89. package/lib/mcp/catalysts/submissions-to-warehouse.md +103 -0
  90. package/lib/mcp/catalysts/weekly-submissions-digest.md +82 -0
  91. package/lib/mcp/jobs.js +64 -0
  92. package/lib/mcp/server.js +184 -0
  93. package/lib/mcp/session-binding.js +130 -0
  94. package/lib/mcp/tools/build.js +123 -0
  95. package/lib/mcp/tools/catalysts.js +477 -0
  96. package/lib/mcp/tools/context.js +325 -0
  97. package/lib/mcp/tools/fleet.js +391 -0
  98. package/lib/mcp/tools/jobs-tools.js +240 -0
  99. package/lib/mcp/tools/operate.js +314 -0
  100. package/lib/preview/build-preview-config.js +136 -0
  101. package/lib/rate-limiter.js +11 -0
  102. package/lib/resolve-api-key.js +142 -0
  103. package/lib/storage/index.js +40 -0
  104. package/messages/de.json +2136 -0
  105. package/messages/en.json +2136 -0
  106. package/messages/es.json +2136 -0
  107. package/messages/fr.json +2136 -0
  108. package/messages/it.json +2136 -0
  109. package/messages/ja.json +2136 -0
  110. package/messages/ko.json +2136 -0
  111. package/messages/nl.json +2136 -0
  112. package/messages/pl.json +2136 -0
  113. package/messages/pt.json +2136 -0
  114. package/messages/ru.json +2136 -0
  115. package/messages/uk.json +2136 -0
  116. package/messages/zh.json +2136 -0
  117. package/package.json +68 -5
  118. package/scripts/mcp-config.mjs +162 -0
  119. package/scripts/mcp-stdio-loader.mjs +42 -0
  120. package/scripts/mcp-stdio.mjs +108 -0
  121. package/scripts/mojulo-paths.mjs +48 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Simple markdown to HTML converter for legal documents
3
+ * Handles: headers, tables, lists, bold, code, links, horizontal rules
4
+ */
5
+ export function markdownToHtml(markdown) {
6
+ let html = markdown;
7
+
8
+ // Escape HTML entities first (except for our own tags)
9
+ html = html
10
+ .replace(/&/g, '&')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;');
13
+
14
+ // Headers (must be at start of line)
15
+ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
16
+ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
17
+ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
18
+
19
+ // Bold and italic
20
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
21
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
22
+
23
+ // Inline code
24
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
25
+
26
+ // Links
27
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
28
+
29
+ // Horizontal rules
30
+ html = html.replace(/^---$/gm, '<hr />');
31
+
32
+ // Tables
33
+ html = convertTables(html);
34
+
35
+ // Lists (unordered)
36
+ html = convertLists(html);
37
+
38
+ // Paragraphs - wrap remaining text blocks
39
+ html = convertParagraphs(html);
40
+
41
+ return html;
42
+ }
43
+
44
+ function convertTables(html) {
45
+ const lines = html.split('\n');
46
+ const result = [];
47
+ let inTable = false;
48
+ let tableRows = [];
49
+
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const line = lines[i];
52
+ const isTableRow = line.trim().startsWith('|') && line.trim().endsWith('|');
53
+ const isSeparator = /^\|[-:\s|]+\|$/.test(line.trim());
54
+
55
+ if (isTableRow && !isSeparator) {
56
+ if (!inTable) {
57
+ inTable = true;
58
+ tableRows = [];
59
+ }
60
+ tableRows.push(line);
61
+ } else if (isSeparator && inTable) {
62
+ // Skip separator row
63
+ continue;
64
+ } else {
65
+ if (inTable) {
66
+ // End of table, convert it
67
+ result.push(buildTable(tableRows));
68
+ inTable = false;
69
+ tableRows = [];
70
+ }
71
+ result.push(line);
72
+ }
73
+ }
74
+
75
+ // Handle table at end of content
76
+ if (inTable) {
77
+ result.push(buildTable(tableRows));
78
+ }
79
+
80
+ return result.join('\n');
81
+ }
82
+
83
+ function buildTable(rows) {
84
+ if (rows.length === 0) return '';
85
+
86
+ let html = '<table>';
87
+
88
+ rows.forEach((row, index) => {
89
+ const cells = row
90
+ .split('|')
91
+ .slice(1, -1)
92
+ .map((cell) => cell.trim());
93
+
94
+ if (index === 0) {
95
+ html += '<thead><tr>';
96
+ cells.forEach((cell) => {
97
+ html += `<th>${cell}</th>`;
98
+ });
99
+ html += '</tr></thead><tbody>';
100
+ } else {
101
+ html += '<tr>';
102
+ cells.forEach((cell) => {
103
+ html += `<td>${cell}</td>`;
104
+ });
105
+ html += '</tr>';
106
+ }
107
+ });
108
+
109
+ html += '</tbody></table>';
110
+ return html;
111
+ }
112
+
113
+ function convertLists(html) {
114
+ const lines = html.split('\n');
115
+ const result = [];
116
+ let inList = false;
117
+
118
+ for (const line of lines) {
119
+ const listMatch = line.match(/^[-*]\s+(.+)$/);
120
+ const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
121
+
122
+ if (listMatch) {
123
+ if (!inList) {
124
+ result.push('<ul>');
125
+ inList = 'ul';
126
+ }
127
+ result.push(`<li>${listMatch[1]}</li>`);
128
+ } else if (numberedMatch) {
129
+ if (!inList) {
130
+ result.push('<ol>');
131
+ inList = 'ol';
132
+ }
133
+ result.push(`<li>${numberedMatch[1]}</li>`);
134
+ } else {
135
+ if (inList) {
136
+ result.push(inList === 'ul' ? '</ul>' : '</ol>');
137
+ inList = false;
138
+ }
139
+ result.push(line);
140
+ }
141
+ }
142
+
143
+ if (inList) {
144
+ result.push(inList === 'ul' ? '</ul>' : '</ol>');
145
+ }
146
+
147
+ return result.join('\n');
148
+ }
149
+
150
+ function convertParagraphs(html) {
151
+ const lines = html.split('\n');
152
+ const result = [];
153
+ let paragraph = [];
154
+
155
+ const isBlockElement = (line) => {
156
+ const trimmed = line.trim();
157
+ return (
158
+ trimmed === '' ||
159
+ trimmed.startsWith('<h') ||
160
+ trimmed.startsWith('<table') ||
161
+ trimmed.startsWith('<ul') ||
162
+ trimmed.startsWith('<ol') ||
163
+ trimmed.startsWith('<li') ||
164
+ trimmed.startsWith('</') ||
165
+ trimmed.startsWith('<hr') ||
166
+ trimmed === '</ul>' ||
167
+ trimmed === '</ol>' ||
168
+ trimmed === '</table>' ||
169
+ trimmed === '</thead>' ||
170
+ trimmed === '</tbody>' ||
171
+ trimmed === '<tbody>' ||
172
+ trimmed.startsWith('<tr') ||
173
+ trimmed.startsWith('<th') ||
174
+ trimmed.startsWith('<td')
175
+ );
176
+ };
177
+
178
+ for (const line of lines) {
179
+ if (isBlockElement(line)) {
180
+ if (paragraph.length > 0) {
181
+ result.push(`<p>${paragraph.join(' ')}</p>`);
182
+ paragraph = [];
183
+ }
184
+ if (line.trim() !== '') {
185
+ result.push(line);
186
+ }
187
+ } else {
188
+ paragraph.push(line);
189
+ }
190
+ }
191
+
192
+ if (paragraph.length > 0) {
193
+ result.push(`<p>${paragraph.join(' ')}</p>`);
194
+ }
195
+
196
+ return result.join('\n');
197
+ }
@@ -0,0 +1,84 @@
1
+ ---
2
+ {
3
+ "id": "appointment-to-calendar",
4
+ "name": "Appointment booking to calendar",
5
+ "summary": "Sync new appointment-protocol bookings into a calendar MCP (Google Calendar, Cal.com, Outlook), with attendee + reminder wiring.",
6
+ "valueHook": "Bookings the bot collects show up as real calendar events for your team, with attendees invited and reminders set — no manual transfer.",
7
+ "version": 1,
8
+ "category": "calendar",
9
+ "requires": {
10
+ "protocols": ["appointments"],
11
+ "destinationMcpCategory": "calendar-like",
12
+ "destinationExamples": ["Google Calendar", "Cal.com", "Outlook Calendar", "Fastmail Calendar"]
13
+ },
14
+ "parameters": [
15
+ {
16
+ "name": "calendarId",
17
+ "prompt": "Which calendar should bookings land in? (calendar id, email, or workspace identifier — depends on the calendar MCP)"
18
+ },
19
+ {
20
+ "name": "defaultDuration",
21
+ "prompt": "Default appointment duration in minutes when the submission doesn't specify one?",
22
+ "default": 30
23
+ },
24
+ {
25
+ "name": "attendeeFields",
26
+ "prompt": "Which submission fields hold attendee identity? (typically email and name)"
27
+ },
28
+ {
29
+ "name": "sendInvites",
30
+ "prompt": "Should the calendar event send email invites to attendees? (true/false)",
31
+ "default": false
32
+ }
33
+ ],
34
+ "mcpTools": {
35
+ "mojulo": ["query_submissions", "get_deployment"],
36
+ "destination": {
37
+ "description": "A calendar-like MCP exposing event create with start/end, attendees, and (optionally) reminders. Examples: Google Calendar, Cal.com, Outlook."
38
+ }
39
+ }
40
+ }
41
+ ---
42
+
43
+ # Appointment booking to calendar
44
+
45
+ The `appointments` protocol captures a user's preferred time and contact info into a submission. This catalyst lifts that submission into a real calendar event so the user (or the booked party) sees it on their schedule.
46
+
47
+ ## How to synthesize the skill
48
+
49
+ 1. `get_deployment(deploymentId)` — read the appointments config and form schema. The appointments protocol stores the captured slot in a known field shape; map it before guessing.
50
+ 2. Ask the user the four `parameters` questions.
51
+ 3. Inspect the destination MCP's event-create surface — timezone handling is the part that varies most. Google Calendar wants `start.dateTime` + `start.timeZone`; Cal.com handles it implicitly via booking type.
52
+ 4. Write `.claude/skills/<bot-slug>-calendar-sync/SKILL.md`.
53
+
54
+ ## Mapping intent
55
+
56
+ The appointment submission typically holds:
57
+
58
+ - A datetime (the booked slot) — UTC ISO or a local time + timezone. **Always normalize to UTC before passing to the calendar MCP**, even if the MCP accepts local; calendar-MCP timezone bugs are the #1 source of off-by-an-hour incidents.
59
+ - Attendee identity (name + email at minimum) from `attendeeFields`.
60
+ - Optional context (chief complaint, service type, notes) → event description.
61
+
62
+ Event composition:
63
+
64
+ - **Title:** `{serviceType or 'Appointment'} — {attendeeName}`
65
+ - **Description:** the submission notes plus a mojulo trace footer (submission id, conversation id, deployment id) so the calendar entry is traceable back to the source conversation.
66
+ - **Duration:** the submission's `duration` field if present, else `defaultDuration`.
67
+ - **Attendees:** the user's calendar always; the booked party only if `sendInvites=true` AND the submission includes a valid email.
68
+
69
+ ## Idempotency
70
+
71
+ Each event create should attach the `mojulo_submission_id` as a custom property (Google Calendar `extendedProperties.private`; Cal.com booking metadata). Search-before-create on that property to avoid duplicates on re-run. The `since` cursor is the primary defense; the property is the safety net.
72
+
73
+ ## Pitfalls
74
+
75
+ - **Timezone bugs.** Already called out above — surface this prominently in the synthesized skill. If the bot serves users across timezones, the appointment slot's timezone has to be carried, not assumed.
76
+ - **`sendInvites` is irreversible.** Once an invite email is sent, it can't be unsent. Default to `false`. Make the user explicitly opt in per run, not just at synthesis time.
77
+ - **Cancellations.** This skill creates events; cancellations through the bot (if any) aren't propagated. If the user needs that flow, it's a separate skill — note this as a limitation.
78
+ - **No-shows / reschedules.** Mojulo doesn't currently observe these. The calendar is the source of truth post-booking.
79
+
80
+ ## Skill behavior contract
81
+
82
+ - **Inputs:** `deploymentId` (required), `since` (optional ISO), `dryRun` (default true), `sendInvites` (default false, requires explicit per-run flag for true)
83
+ - **Outputs:** per-submission decision log `{ submissionId, calendarEventId?, action: 'created' | 'duplicate-skipped' | 'invalid-slot' }`
84
+ - **Side effects (live mode):** calendar event create via destination MCP. Email invites only when `sendInvites=true`.
@@ -0,0 +1,104 @@
1
+ ---
2
+ {
3
+ "id": "conversations-to-channel-digest",
4
+ "name": "Conversation digest to channel",
5
+ "summary": "Generate a recurring narrative summary of what end users have been saying to the bot — themes, recurring questions, sentiment, notable conversations — and post to a channel (Slack/email/Notion).",
6
+ "valueHook": "A recurring narrative of what users are actually saying to the bot, posted where your team already pays attention.",
7
+ "version": 1,
8
+ "category": "digest",
9
+ "requires": {
10
+ "protocols": [],
11
+ "destinationMcpCategory": "channel-like",
12
+ "destinationExamples": ["Slack", "Gmail", "Notion", "Microsoft Teams", "Discord"]
13
+ },
14
+ "parameters": [
15
+ {
16
+ "name": "cadenceDescription",
17
+ "prompt": "How often will this run and what window should each digest cover? (e.g., 'weekly, covering the prior 7 days')"
18
+ },
19
+ {
20
+ "name": "summaryAxes",
21
+ "prompt": "What dimensions of conversation should the digest highlight? (e.g., 'recurring questions, sentiment trends, novel topics, escalation candidates' — pick 2-4)"
22
+ },
23
+ {
24
+ "name": "sampleCeiling",
25
+ "prompt": "If the window has more conversations than this number, sample at this size rather than read everything. Defaults to 100; lower for cost control, higher for completeness.",
26
+ "default": 100
27
+ },
28
+ {
29
+ "name": "outputChannel",
30
+ "prompt": "Where does this land? (e.g., 'Slack #cs-insights', 'email to team@example.com', 'Notion page in workspace X')"
31
+ },
32
+ {
33
+ "name": "audienceTone",
34
+ "prompt": "Who reads this and how formal should the summary be? (e.g., 'engineering team — terse, bullet-heavy', 'leadership — narrative, qualitative', 'support manager — actionable, ticket-oriented')"
35
+ }
36
+ ],
37
+ "mcpTools": {
38
+ "mojulo": ["query_conversations", "get_conversation", "get_deployment"],
39
+ "destination": {
40
+ "description": "A channel-like MCP that posts narrative content. Slack (post_message), Gmail (send_email), Notion (create_page or append_block), Microsoft Teams, Discord, or any messaging surface."
41
+ }
42
+ }
43
+ }
44
+ ---
45
+
46
+ # Conversation digest to channel
47
+
48
+ This catalyst is distinct from `weekly-submissions-digest`: that one summarizes *structured submissions* (counts, breakdowns, notable rows). This one summarizes *conversation content* — what end users actually said to the bot, in their own words. Two very different sources, two very different digest shapes. Many bots benefit from both running in parallel — submissions tell you *what was captured*, conversations tell you *what was asked*.
49
+
50
+ The output is a narrative report posted to a channel where the audience reads it without clicking through to the dashboard. The value is keeping the operating team aware of how the bot is being *used* without anyone manually scrubbing conversations.
51
+
52
+ ## How to synthesize the skill
53
+
54
+ 1. `get_deployment(deploymentId)` — read the bot's identity and protocols. The identity (industry, role, customer base) shapes how you interpret what users are saying; "frustration" means different things on a dental-intake bot vs. a SaaS-support bot.
55
+ 2. Ask the user the five `parameters` questions, batched.
56
+ 3. Inspect the destination MCP's post surface — markdown support, message length limits, threading capability. Slack's `post_message` has length limits and benefits from a `blocks` payload; email allows long-form HTML; Notion allows arbitrarily long structured pages. The digest's render form adapts to the destination.
57
+ 4. Write `.claude/skills/<bot-slug>-conv-digest/SKILL.md`. The skill takes `deploymentId`, `windowStart`, `windowEnd` as inputs.
58
+
59
+ ## Digest composition
60
+
61
+ Four sections, in this order:
62
+
63
+ 1. **Header** — bot name, window covered, total conversations, total turns, average conversation length. One line each.
64
+ 2. **Recurring questions / themes** — cluster conversations by the user's underlying question or topic. Surface the top 3-7 clusters with: a canonical phrasing of the question, observation count, 1-2 representative quotes (PII-redacted), and any pattern in how the bot handled them. This is the section the audience reads most carefully — it's the closest thing to "voice of the customer" from the bot's vantage.
65
+ 3. **Sentiment / friction signals** — conversations where the user expressed frustration, repeated the same question, gave up, or escalated. Bounded list (top 3-5), each with conversation id and a one-line summary. Distinguish "user gave up because bot couldn't help" from "user got what they needed and left" — the former is the actionable signal.
66
+ 4. **Novel topics** (optional, if window > 2 weeks) — questions or topics that appeared this window but not in prior windows. Catches drift in customer concerns over time. Skip in narrow-window digests; the signal-to-noise is bad below ~2 weeks.
67
+
68
+ ## Sampling discipline
69
+
70
+ Conversation reading is expensive (every conversation requires a `get_conversation` call + LLM read). The `sampleCeiling` defaults to 100 to keep cost predictable. If the window has more conversations than that:
71
+
72
+ - For clustering (themes/questions): random sample to ceiling. Quality plateaus around 100 for most clustering work; doubling rarely doubles signal.
73
+ - For friction signals: prioritize keeping the most recent N rather than random — fresh frustration matters more than old.
74
+
75
+ `query_conversations` returns summaries cheaply; use those to make the sampling decision before calling `get_conversation` for the full turns. This is the key efficiency trick for this catalyst.
76
+
77
+ ## Output adaptation per destination
78
+
79
+ - **Slack** — `blocks` payload, bullet-heavy, each theme in its own section. Length cap matters; if the digest is long, post a summary in the channel and link to a thread with the full content.
80
+ - **Email** — long-form HTML or markdown is fine. Include a TL;DR at the top for the inbox preview.
81
+ - **Notion** — structured page with headings per section. Notion preserves rich-text and tables well; lean into that. Search-before-create on the page title to update an existing digest rather than spawn duplicates per run.
82
+ - **Teams/Discord** — similar to Slack but the API shapes differ; adapt to what the bound MCP exposes.
83
+
84
+ ## Idempotency
85
+
86
+ Less critical than for write-side catalysts — re-running just re-posts. But:
87
+
88
+ - **Notion/Doc destinations:** search-before-create on the page title to update rather than spawn duplicates.
89
+ - **Slack/email destinations:** no idempotency surface. Default the synthesized skill to `--dry-run` mode that prints the digest to stdout; `--send` required for live posting.
90
+ - **Empty windows:** a bot with no conversations in the window shouldn't produce a noisy "0 conversations" digest. Default to skip-when-empty unless the user explicitly wants the heartbeat.
91
+
92
+ ## Pitfalls
93
+
94
+ - **PII in quotes.** Sample utterances may contain names, emails, account numbers, location. The digest's value is the *pattern*, not the asker. Redact aggressively before including any direct quote — substitute placeholders for identity. The redaction step is non-negotiable in the synthesized skill; don't make it optional.
95
+ - **Over-summarization hides the signal.** Resist the urge to compress every quote to a generic "users asked about pricing." A specific quote — properly redacted — communicates the texture of what users actually said, which is the point. Aim for 1-2 lightly-edited verbatim quotes per cluster.
96
+ - **Calibration drift.** "Frustration" or "novel topic" are model judgements. If the bot's domain shifts (new product launches, new customer segment), the model's calibration drifts. Recommend the user re-run the catalyst flow when the bot's identity or domain changes substantially.
97
+ - **Don't surface conversations that ended in handoff.** If `triage` is enabled, conversations that handed off to another bot already got attention from that downstream — including them as "friction" double-counts. Filter handoffs out of the friction signal section unless the user wants them.
98
+ - **Volume bias.** A loud, repeating user can dominate a recurring-question cluster. When sampling, deduplicate by conversation id (one observation per user) before counting frequency.
99
+
100
+ ## Skill behavior contract
101
+
102
+ - **Inputs:** `deploymentId` (required), `windowStart` and `windowEnd` (optional ISO — defaults derived from cadence), `sampleCeiling` (default from parameter), `dryRun` (default true)
103
+ - **Outputs:** the rendered digest (printed in dry-run mode; posted otherwise)
104
+ - **Side effects (live mode):** one document/message create or update via destination MCP. No mojulo-side writes.
@@ -0,0 +1,92 @@
1
+ ---
2
+ {
3
+ "id": "document-extract-to-store",
4
+ "name": "Optical extraction to durable store",
5
+ "summary": "Persist optical-read extractions to a structured store (Notion/Airtable/Sheets rows) or a vector store (Pinecone/Qdrant/Chroma chunks), preserving traceability back to the source image and submission.",
6
+ "valueHook": "Photos and screenshots the bot reads become queryable rows or searchable embeddings — extractions stop being one-shot.",
7
+ "version": 1,
8
+ "category": "extraction-pipeline",
9
+ "requires": {
10
+ "protocols": ["opticalRead"],
11
+ "optionalProtocols": ["formGathering"],
12
+ "destinationMcpCategory": "data-store-like",
13
+ "destinationExamples": ["Notion", "Airtable", "Google Sheets", "Pinecone", "Qdrant"]
14
+ },
15
+ "parameters": [
16
+ {
17
+ "name": "destinationMode",
18
+ "prompt": "Where should extracted fields land — a structured table (Notion/Airtable/Sheets, rows + columns), or a vector store (Pinecone/Qdrant/Chroma, chunks + embeddings)? If the user has only one of the two installed, pick that and confirm."
19
+ },
20
+ {
21
+ "name": "recordKey",
22
+ "prompt": "Which field uniquely identifies a record for dedupe? (typically a document number, claim id, policy id, or a hash of the extracted-field tuple when no natural key exists)"
23
+ },
24
+ {
25
+ "name": "fieldMapping",
26
+ "prompt": "How should the bot's extractedFields map to the destination? For table mode: field name → column name pairs. For vector mode: which fields are chunked, which become metadata filters?"
27
+ },
28
+ {
29
+ "name": "imageRetention",
30
+ "prompt": "Should the synthesized skill include a URL/path back to the original image in each record? (true/false — depends on whether the bot serves the image bytes long-term)",
31
+ "default": false
32
+ }
33
+ ],
34
+ "mcpTools": {
35
+ "mojulo": ["query_submissions", "get_conversation", "get_deployment"],
36
+ "destination": {
37
+ "description": "A data-store-like MCP. Two shapes are supported: (a) structured table MCPs (Notion, Airtable, Google Sheets, Coda) exposing row create/upsert with named columns; (b) vector store MCPs (Pinecone, Qdrant, Chroma, Weaviate) exposing embed + upsert with metadata. The synthesized skill commits to one shape per skill instance — write two skills if the user wants both."
38
+ }
39
+ }
40
+ }
41
+ ---
42
+
43
+ # Optical extraction to durable store
44
+
45
+ The `opticalRead` protocol turns uploaded images (claim forms, IDs, lab results, receipts, contracts) into a structured `extractedFields` payload that gets attached to the submission. This catalyst takes that structured output and persists it to a long-term store where downstream systems — analytics, lookup tools, RAG corpora — can use it.
46
+
47
+ ## How to synthesize the skill
48
+
49
+ 1. `get_deployment(deploymentId)` — read the optical-read configuration. The `extractedFields` schema (`idName`, `label`, `hint`) tells you exactly what fields each scan produces. **This is your source-of-truth for `fieldMapping`** — never invent fields the bot doesn't extract.
50
+ 2. Ask the user the four `parameters` questions, batched. The `destinationMode` answer is the load-bearing branch — table mode and vector mode synthesize different skills.
51
+ 3. Inspect the bound destination MCP. Confirm it matches `destinationMode` (a row-creation surface for table mode, an embed+upsert surface for vector mode). If the user has a vector store MCP but answered "table," ask — don't force-fit.
52
+ 4. Write `.claude/skills/<bot-slug>-extract-to-<destination-slug>/SKILL.md`. The skill takes `deploymentId` and `since` as inputs.
53
+
54
+ ## Mapping intent — table mode (Notion, Airtable, Sheets, Coda)
55
+
56
+ Each submission with an `extractedFields` payload becomes one row. Columns are derived from the `fieldMapping`:
57
+
58
+ - **Identity column** — the `recordKey` field. Used for upsert (search-before-create); this is the row's primary key from the destination's perspective.
59
+ - **Data columns** — one per extracted field. Map `idName` to the destination column. Preserve types: dates as dates, currency as numbers, strings as strings. Do not coerce everything to text.
60
+ - **Mojulo trace columns** — `mojulo_submission_id`, `mojulo_deployment_id`, `mojulo_captured_at`, optionally `mojulo_conversation_id`. Always include. The reviewer downstream needs to walk back to the source conversation when an extraction looks wrong.
61
+ - **Confidence/quality columns (optional)** — if the optical-read output carries per-field confidence, surface it. A column like `extraction_quality: 'high' | 'medium' | 'low'` lets the reviewer prioritize what to spot-check.
62
+
63
+ Field-to-column mapping that doesn't fit — extracted fields with no destination column — should prompt the user during synthesis, not be silently dropped. If the destination has a JSON/blob column, fall back to a `raw_extraction` JSON dump for unmapped fields; otherwise ask.
64
+
65
+ ## Mapping intent — vector mode (Pinecone, Qdrant, Chroma, Weaviate)
66
+
67
+ Each submission with an `extractedFields` payload becomes one **or more** vector records. The chunking and metadata design is where vector mode earns its keep:
68
+
69
+ - **Chunking choice.** Two reasonable defaults: (a) one chunk per submission, concatenating `label: value` pairs into a single text string for embedding; (b) one chunk per extracted field, embedded as `<field label>: <value>` so semantic search can find documents matching a specific field pattern. Default to (a) unless the user's intent (per `fieldMapping`) names specific fields as standalone search targets.
70
+ - **Metadata.** Every chunk carries: `submission_id`, `deployment_id`, `captured_at`, `record_key` (the value of the `recordKey` field). Also any extracted field the user named as a metadata filter — these become the structured-filter dimensions for hybrid retrieval (e.g., `claim_year: 2026`).
71
+ - **Embedding choice.** The destination MCP usually exposes embedding internally (Pinecone has its own; Qdrant integrates with several). Use the destination's own embedding pipeline rather than re-embedding from Claude. If the destination requires pre-embedded vectors, the user has to provide an embedding tool (separate MCP or local helper) — this is the one case to ask before assuming.
72
+ - **Namespace / collection.** Default to per-deployment namespace (`mojulo_<deploymentId>`), so multiple bots writing to the same vector store don't pollute each other.
73
+
74
+ ## Idempotency
75
+
76
+ **Both modes** use `since` as the primary high-water cursor on submission timestamp. Search-before-upsert on `recordKey` is the safety net for re-runs and duplicate submissions.
77
+
78
+ **Vector mode adds a wrinkle:** if `chunkStrategy` is "per-field" and the same submission is reprocessed, you get N chunks per submission and need to delete the prior N before re-upserting. Most vector MCPs expose a `delete-by-metadata` (filter on `submission_id`) — use it before upsert. The synthesized skill should make this explicit; silent N+N+N growth on re-runs is the most common bug here.
79
+
80
+ ## Pitfalls
81
+
82
+ - **Extraction confidence is variable.** Optical-read is not perfect. Documents with low confidence shouldn't be auto-promoted to a system-of-record store. Recommend the synthesized skill default to a confidence threshold (e.g., skip-and-log when any required field is below `medium`), with the user opting into "include all" if they're staging for review.
83
+ - **PII in the destination.** Optical-read often captures sensitive fields (DOB, SSN, insurance ids, addresses). Tables and vector stores typically have broader access than the bot's own SQLite. Confirm with the user during synthesis which fields should be redacted, hashed, or excluded entirely before landing. Default to including everything the user says to include — but the question is non-skippable.
84
+ - **Vector store costs scale with rerun.** Vector upserts cost per-vector and per-embedding-call. A wide `since` window on first run can be expensive. Recommend starting with a 1-day window, validating the chunk shape, then widening.
85
+ - **Schema drift.** If the bot's `opticalRead` extraction fields change later (new field added, label renamed), the table schema or vector metadata schema will silently misalign. The synthesized skill should fail-loud on schema mismatch rather than silently dropping fields — and recommend the user re-run the catalyst flow when the bot's extraction config changes.
86
+ - **Image retention is a side concern.** If `imageRetention=true`, the URL/path included in each record only stays valid as long as the bot serves the image. If the bot rotates or deletes old uploads, the link breaks. Don't promise long-term access the bot doesn't deliver.
87
+
88
+ ## Skill behavior contract
89
+
90
+ - **Inputs:** `deploymentId` (required), `since` (optional ISO, default 24h ago or last-cursor), `confidenceThreshold` (string, default `medium`), `dryRun` (default true)
91
+ - **Outputs:** per-submission decision log: `{ submissionId, recordKey, action: 'inserted' | 'updated' | 'skipped-low-confidence' | 'skipped-duplicate' | 'failed', destinationRecordId?, chunkCount? }`. Vector mode adds `chunkCount` per record.
92
+ - **Side effects (live mode):** row create/upsert (table mode) or chunk delete+upsert (vector mode) via destination MCP. No mojulo-side writes.
@@ -0,0 +1,96 @@
1
+ ---
2
+ {
3
+ "id": "knowledge-gap-miner",
4
+ "name": "Knowledge gap miner",
5
+ "summary": "Analyze recent conversations on a knowledge-protocol bot to find questions the RAG corpus answered poorly, and propose additions to the user's documentation backlog.",
6
+ "valueHook": "Find the questions your RAG corpus is answering badly, so your docs can fill the gap before users notice.",
7
+ "version": 1,
8
+ "category": "rag-curation",
9
+ "requires": {
10
+ "protocols": ["knowledge"],
11
+ "destinationMcpCategory": "optional-doc-backlog",
12
+ "destinationExamples": ["Notion", "Linear", "GitHub Issues"]
13
+ },
14
+ "parameters": [
15
+ {
16
+ "name": "lookbackWindow",
17
+ "prompt": "How far back should this scan? (e.g., '7 days', '30 days')",
18
+ "default": "14 days"
19
+ },
20
+ {
21
+ "name": "minOccurrences",
22
+ "prompt": "How many times must a gap be observed before it's worth surfacing?",
23
+ "default": 2
24
+ },
25
+ {
26
+ "name": "backlogDestination",
27
+ "prompt": "Where should proposed doc additions go? (e.g., 'a Notion page', 'a Linear ticket per gap', 'just print to stdout' — leave empty for stdout-only)"
28
+ }
29
+ ],
30
+ "mcpTools": {
31
+ "mojulo": ["query_conversations", "get_conversation", "get_deployment"],
32
+ "destination": {
33
+ "description": "Optional. If specified, a doc/backlog MCP that can accept proposed additions. Examples: Notion (create_page), Linear (issue_create), GitHub (create_issue)."
34
+ }
35
+ }
36
+ }
37
+ ---
38
+
39
+ # Knowledge gap miner
40
+
41
+ A `knowledge`-protocol bot answers from its RAG corpus. When it doesn't have a good answer — vague reply, hedged response, "I don't have information about that" — that's a signal the corpus is missing something users actually ask about. This catalyst mines those signals and turns them into a deduplicated, prioritized backlog of doc additions.
42
+
43
+ Unlike the other catalysts, the destination is **optional**. The most useful output is often just the printed list — a focused weekly review by whoever owns the corpus. A backlog MCP is a nice-to-have.
44
+
45
+ ## How to synthesize the skill
46
+
47
+ 1. `get_deployment(deploymentId)` — confirm the `knowledge` protocol is active. Read the bot's domain identity; it shapes how you interpret "gap."
48
+ 2. Ask the user the three `parameters` questions.
49
+ 3. If `backlogDestination` was given, inspect that MCP's create surface.
50
+ 4. Write `.claude/skills/<bot-slug>-gap-miner/SKILL.md`.
51
+
52
+ ## Detection logic
53
+
54
+ Walk recent conversations (`query_conversations` with `since` derived from `lookbackWindow`, then `get_conversation` per id). For each conversation, scan the bot's turns for **weak-answer signals**:
55
+
56
+ - Explicit declines: "I don't have information about that," "I can't find that in my knowledge base," "you'd need to contact support for that"
57
+ - Hedging: "based on what I can tell," "I'm not entirely sure," "you may want to verify"
58
+ - Topic-deflection: bot answers a *related* question rather than the one asked
59
+ - User dissatisfaction cues: user re-phrases the same question, user says "that's not what I asked," user abandons the conversation after a vague answer
60
+
61
+ For each weak-answer turn, extract the **user's underlying question** as a short canonical phrasing (not a quote — a generalization). This is the gap.
62
+
63
+ ## Clustering and dedup
64
+
65
+ Cluster gaps by semantic similarity across the window. One user asking "what are your hours" three times is one gap, three observations. Three different users asking variations of "how do I cancel" is one gap, three observations.
66
+
67
+ Surface only clusters with ≥ `minOccurrences` observations. This filters one-off questions from genuine corpus gaps.
68
+
69
+ ## Proposal composition
70
+
71
+ For each surfaced gap, generate:
72
+
73
+ - **Canonical question** — the gap as a documentable Q
74
+ - **Observation count** — how many conversations hit this
75
+ - **Sample utterances** — 2-3 actual phrasings from real conversations (with conversation ids for traceability)
76
+ - **Proposed addition** — a short paragraph the user could paste into their docs as a starting point. Mark this clearly as **proposed, not authoritative** — the user must review before adding to the corpus.
77
+
78
+ The user re-uploads accepted additions through the normal mojulo document-upload flow ([upload_document_from_url](docs/mcp-integration.md) tool) — this skill does **not** modify the bot's corpus directly.
79
+
80
+ ## Output
81
+
82
+ - **Always:** a markdown report printed to stdout (or, in Claude Code, returned as the skill's result text). The user reads it.
83
+ - **If `backlogDestination` is configured:** one entry per surfaced gap in the destination. For Linear: one issue per gap. For Notion: one page (or one row in a database). Each entry includes the conversation ids so the reviewer can drill back.
84
+
85
+ ## Pitfalls
86
+
87
+ - **Weak-answer false positives.** A bot that's been told to hedge ("I'm an AI, please verify with...") will look like it has gaps everywhere. Calibrate by reading the bot's identity prompt — if hedging is configured behavior, raise the bar for what counts as weak.
88
+ - **PII in the report.** Sample utterances may contain identity. Redact aggressively — the report's value is the *question pattern*, not the asker. Replace names/emails/specific identifiers with placeholders before including.
89
+ - **Don't auto-add to corpus.** The corpus is the bot's behavior. Silent additions are surprise behavior changes. Always go through the user — propose, never inject.
90
+ - **Cadence.** Once-a-week or once-a-month is plenty. Running this daily produces noise and the corpus doesn't change that fast.
91
+
92
+ ## Skill behavior contract
93
+
94
+ - **Inputs:** `deploymentId` (required), `lookbackWindow` (default 14d), `minOccurrences` (default 2), `dryRun` (default true)
95
+ - **Outputs:** the gap report (always), per-gap destination action results (when configured)
96
+ - **Side effects (live mode, only if destination configured):** one entry per gap in the destination. **Never writes to the bot's corpus** — that path is user-mediated through document upload.