@telvok/librarian-mcp 2.3.2 → 2.4.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.
@@ -1,32 +1,3 @@
1
- interface PublishResult {
2
- success: boolean;
3
- message: string;
4
- publish_token?: string;
5
- book?: {
6
- id?: string;
7
- slug: string;
8
- name: string;
9
- url: string;
10
- };
11
- entries_count?: number;
12
- setup_url?: string;
13
- preview?: boolean;
14
- summary?: {
15
- name: string;
16
- pricing: {
17
- type: string;
18
- display: string;
19
- };
20
- entries_count: number;
21
- entries: Array<{
22
- title: string;
23
- file: string;
24
- }>;
25
- };
26
- next_steps?: string;
27
- options?: Record<string, string>;
28
- required?: Record<string, string>;
29
- }
30
1
  export declare const libraryPublishTool: {
31
2
  name: string;
32
3
  title: string;
@@ -34,12 +5,11 @@ export declare const libraryPublishTool: {
34
5
  inputSchema: {
35
6
  type: "object";
36
7
  properties: {
37
- name: {
38
- type: string;
39
- description: string;
40
- };
41
- description: {
8
+ entries: {
42
9
  type: string;
10
+ items: {
11
+ type: string;
12
+ };
43
13
  description: string;
44
14
  };
45
15
  pricing: {
@@ -48,14 +18,11 @@ export declare const libraryPublishTool: {
48
18
  type: {
49
19
  type: string;
50
20
  enum: string[];
51
- description: string;
52
21
  };
53
22
  price_cents: {
54
23
  type: string;
55
- description: string;
56
24
  };
57
25
  };
58
- required: string[];
59
26
  description: string;
60
27
  };
61
28
  consumption: {
@@ -63,38 +30,12 @@ export declare const libraryPublishTool: {
63
30
  enum: string[];
64
31
  description: string;
65
32
  };
66
- attestation: {
67
- type: string;
68
- properties: {
69
- original_work: {
70
- type: string;
71
- description: string;
72
- };
73
- no_secrets: {
74
- type: string;
75
- description: string;
76
- };
77
- terms_accepted: {
78
- type: string;
79
- description: string;
80
- };
81
- };
82
- required: string[];
83
- description: string;
84
- };
85
- preview: {
86
- type: string;
87
- description: string;
88
- };
89
- publish_token: {
33
+ name: {
90
34
  type: string;
91
35
  description: string;
92
36
  };
93
- entries: {
37
+ description: {
94
38
  type: string;
95
- items: {
96
- type: string;
97
- };
98
39
  description: string;
99
40
  };
100
41
  tags: {
@@ -107,11 +48,60 @@ export declare const libraryPublishTool: {
107
48
  license: {
108
49
  type: string;
109
50
  enum: string[];
51
+ };
52
+ confirm: {
53
+ type: string;
110
54
  description: string;
111
55
  };
112
56
  };
113
- required: string[];
57
+ required: never[];
114
58
  };
115
- handler(args: unknown): Promise<PublishResult>;
59
+ handler(args: unknown): Promise<{
60
+ success: boolean;
61
+ message: string;
62
+ setup_url: any;
63
+ book?: undefined;
64
+ entries_count?: undefined;
65
+ } | {
66
+ success: boolean;
67
+ message: any;
68
+ setup_url?: undefined;
69
+ book?: undefined;
70
+ entries_count?: undefined;
71
+ } | {
72
+ success: boolean;
73
+ message: string;
74
+ book: any;
75
+ entries_count: any;
76
+ setup_url?: undefined;
77
+ } | {
78
+ success: boolean;
79
+ step: string;
80
+ message: string;
81
+ entries_count: number;
82
+ ask_user: string;
83
+ selected_count?: undefined;
84
+ } | {
85
+ success: boolean;
86
+ step: string;
87
+ message: string;
88
+ ask_user: string;
89
+ entries_count?: undefined;
90
+ selected_count?: undefined;
91
+ } | {
92
+ success: boolean;
93
+ step: string;
94
+ message: string;
95
+ entries_count?: undefined;
96
+ ask_user?: undefined;
97
+ selected_count?: undefined;
98
+ } | {
99
+ success: boolean;
100
+ step: string;
101
+ message: string;
102
+ selected_count: number;
103
+ ask_user: string;
104
+ entries_count?: undefined;
105
+ }>;
116
106
  };
117
107
  export default libraryPublishTool;
@@ -1,349 +1,370 @@
1
1
  // ============================================================================
2
- // Marketplace Publish Tool
3
- // Publish local entries as a book on Telvok library
2
+ // Marketplace Publish Tool — Multi-Step Wizard
3
+ // Each call advances one step. Tool refuses to skip ahead.
4
+ // Agent relays between user and tool. User makes every decision.
4
5
  // ============================================================================
5
6
  import * as fs from 'fs/promises';
6
7
  import * as path from 'path';
7
- import * as crypto from 'crypto';
8
8
  import { glob } from 'glob';
9
9
  import matter from 'gray-matter';
10
10
  import { loadApiKey } from './auth.js';
11
11
  import { getLibraryPath, getLocalPath } from '../library/storage.js';
12
12
  import { scanForSensitiveData } from '../library/sensitive-scanner.js';
13
13
  const TELVOK_API_URL = process.env.TELVOK_API_URL || 'https://telvok.com';
14
- const TOKEN_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
15
- let pendingPublish = null;
14
+ const WIZARD_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
15
+ let wizardState = null;
16
+ function clearExpiredWizard() {
17
+ if (wizardState && Date.now() - wizardState.created > WIZARD_EXPIRY_MS) {
18
+ wizardState = null;
19
+ }
20
+ }
16
21
  // ============================================================================
17
22
  // Tool Definition
18
23
  // ============================================================================
19
24
  export const libraryPublishTool = {
20
25
  name: 'library_publish',
21
26
  title: 'Publish Book',
22
- description: `Publish local entries as a book on Telvok library.
23
-
24
- ⚠️ TWO-STEP PUBLISH FLOW (MANDATORY):
27
+ description: `Publish local entries as a book on Telvok marketplace.
25
28
 
26
- Step 1: ALWAYS call with preview: true first. This shows what will be published
27
- and returns a publish_token. Show the preview to the user and ASK FOR CONFIRMATION.
29
+ This is a GUIDED WIZARD. Call with no arguments to start.
30
+ The tool walks through each step DO NOT try to provide all arguments at once.
28
31
 
29
- Step 2: ONLY after the user explicitly confirms, call again with the publish_token
30
- from the preview response. Publishing WITHOUT a valid token will be rejected.
32
+ FLOW:
33
+ 1. library_publish() scans entries, shows audit. Ask user which to include.
34
+ 2. library_publish({ entries: ["all"] }) → shows pricing options. Ask user to choose.
35
+ 3. library_publish({ pricing: { type: "open" } }) → asks for name/description.
36
+ 4. library_publish({ name: "...", description: "..." }) → shows summary, requires user confirmation.
31
37
 
32
- DO NOT skip the preview. DO NOT publish without user confirmation.
33
- The tool will refuse to publish without a valid publish_token from a preview.
38
+ Each step REQUIRES the user's input before proceeding. DO NOT decide for the user.
39
+ DO NOT provide name, pricing, entries, or description without asking the user first.
34
40
 
35
- TRIGGER PATTERNS:
36
- - "Publish my entries" → library_publish({ name: "...", pricing: { type: "open" }, preview: true })
37
- - User says "yes, publish it" → library_publish({ ..., publish_token: "<token from preview>" })
38
-
39
- Examples:
40
- - Preview: library_publish({ name: "My Book", pricing: { type: "open" }, preview: true })
41
- - Publish: library_publish({ name: "My Book", pricing: { type: "open" }, consumption: "download", attestation: { original_work: true, no_secrets: true, terms_accepted: true }, publish_token: "abc123" })`,
41
+ If the user says "publish my entries" — call library_publish() with NO args to start the wizard.`,
42
42
  inputSchema: {
43
43
  type: 'object',
44
44
  properties: {
45
- name: {
46
- type: 'string',
47
- description: 'Book title (3-100 characters)',
48
- },
49
- description: {
50
- type: 'string',
51
- description: 'Short description of the book (optional, max 500 chars)',
45
+ entries: {
46
+ type: 'array',
47
+ items: { type: 'string' },
48
+ description: 'Entry filenames to include. Use ["all"] for everything. Only provide after showing user the entry list.',
52
49
  },
53
50
  pricing: {
54
51
  type: 'object',
55
52
  properties: {
56
- type: {
57
- type: 'string',
58
- enum: ['open', 'one_time', 'subscription'],
59
- description: 'Pricing model',
60
- },
61
- price_cents: {
62
- type: 'number',
63
- description: 'Price in cents (required for paid, min 100 = $1.00)',
64
- },
53
+ type: { type: 'string', enum: ['open', 'one_time', 'subscription'] },
54
+ price_cents: { type: 'number' },
65
55
  },
66
- required: ['type'],
67
- description: 'Pricing configuration',
56
+ description: 'Only provide after showing user the pricing options.',
68
57
  },
69
58
  consumption: {
70
59
  type: 'string',
71
60
  enum: ['inline', 'reference', 'download'],
72
- description: 'How buyers access content. download only for free books.',
73
- },
74
- attestation: {
75
- type: 'object',
76
- properties: {
77
- original_work: { type: 'boolean', description: 'Confirm this is original work' },
78
- no_secrets: { type: 'boolean', description: 'Confirm no secrets/credentials' },
79
- terms_accepted: { type: 'boolean', description: 'Accept library terms' },
80
- },
81
- required: ['original_work', 'no_secrets', 'terms_accepted'],
82
- description: 'Required confirmations before publishing',
83
- },
84
- preview: {
85
- type: 'boolean',
86
- description: 'If true, show what would be published without publishing. Returns a publish_token.',
87
- },
88
- publish_token: {
89
- type: 'string',
90
- description: 'Token from preview response. Required to actually publish. Single-use, expires in 5 minutes.',
91
- },
92
- entries: {
93
- type: 'array',
94
- items: { type: 'string' },
95
- description: 'Specific entry filenames to include (omit for all local/)',
96
- },
97
- tags: {
98
- type: 'array',
99
- items: { type: 'string' },
100
- description: 'Category/topic tags (max 10)',
101
- },
102
- license: {
103
- type: 'string',
104
- enum: ['open', 'open_attributed', 'personal'],
105
- description: 'License type (default: personal)',
61
+ description: 'How buyers access content. Only relevant for paid books.',
106
62
  },
63
+ name: { type: 'string', description: 'Book title. Only provide after user tells you what to call it.' },
64
+ description: { type: 'string', description: 'Book description. Only provide after user writes or approves it.' },
65
+ tags: { type: 'array', items: { type: 'string' }, description: 'Topic tags (max 10).' },
66
+ license: { type: 'string', enum: ['open', 'open_attributed', 'personal'] },
67
+ confirm: { type: 'boolean', description: 'Set to true ONLY after showing the summary to the user and they say yes.' },
107
68
  },
108
- required: ['name', 'pricing'],
69
+ required: [],
109
70
  },
110
71
  async handler(args) {
111
- const { name, description, pricing, consumption, attestation, preview, publish_token, entries: entryFilter, tags, license } = args;
112
- // Validate name
113
- if (!name || typeof name !== 'string' || name.trim().length < 3) {
114
- throw new Error('Book name is required (minimum 3 characters)');
115
- }
116
- if (name.trim().length > 100) {
117
- throw new Error('Book name must be 100 characters or less');
118
- }
119
- // Validate pricing
120
- if (!pricing || !pricing.type) {
121
- throw new Error('Pricing type is required');
122
- }
123
- if (!['open', 'one_time', 'subscription'].includes(pricing.type)) {
124
- throw new Error('Pricing type must be: open, one_time, or subscription');
125
- }
126
- if (pricing.type !== 'open' && (!pricing.price_cents || pricing.price_cents < 100)) {
127
- throw new Error('Paid books require price_cents >= 100 ($1.00)');
128
- }
129
- if (pricing.price_cents && pricing.price_cents > 100000) {
130
- throw new Error('Price cannot exceed $1000.00 (100000 cents)');
131
- }
132
- // Validate description length
133
- if (description && description.length > 500) {
134
- throw new Error('Description must be 500 characters or less');
135
- }
136
- // Validate tags count
137
- if (tags && tags.length > 10) {
138
- throw new Error('Maximum 10 tags allowed');
139
- }
140
- // Collect entries from local/ (needed for preview and publish)
141
- const collectedEntries = await collectLocalEntries(entryFilter);
142
- // Scan for sensitive data before publishing
143
- const sensitiveFindings = scanForSensitiveData(collectedEntries);
144
- if (sensitiveFindings.length > 0) {
145
- const warnings = sensitiveFindings.map(f => ` ⚠ ${f.entry}: ${f.matches.join(', ')}`).join('\n');
146
- if (preview) {
147
- // In preview mode, show warnings but continue
72
+ const input = (args || {});
73
+ clearExpiredWizard();
74
+ // ======================================================================
75
+ // STEP 1: No wizard state Scan entries, show audit
76
+ // ======================================================================
77
+ if (!wizardState) {
78
+ const allEntries = await collectLocalEntries();
79
+ if (allEntries.length === 0) {
148
80
  return {
149
- success: true,
150
- preview: true,
151
- message: `⚠ SENSITIVE DATA DETECTED in ${sensitiveFindings.length} entry(s):\n${warnings}\n\nReview these entries before publishing. Remove credentials, API keys, passwords, and personal data.`,
81
+ success: false,
82
+ message: 'No entries found in .librarian/local/. Use record() to create entries first.',
152
83
  };
153
84
  }
154
- // In publish mode, block and require cleanup
155
- return {
156
- success: false,
157
- message: `🚫 Publish blocked — sensitive data detected in ${sensitiveFindings.length} entry(s):\n${warnings}\n\nClean up these entries with record() or delete() before publishing. Use library_publish({ preview: true }) to re-check.`,
158
- };
159
- }
160
- if (collectedEntries.length === 0) {
161
- return {
162
- success: false,
163
- message: 'No entries found in .librarian/local/. Use record() to create entries first.',
164
- };
165
- }
166
- // Format pricing display
167
- const pricingDisplay = pricing.type === 'open'
168
- ? 'Free'
169
- : `$${((pricing.price_cents || 0) / 100).toFixed(2)}`;
170
- // Handle preview mode - return summary with publish token
171
- if (preview) {
172
- const token = crypto.randomBytes(16).toString('hex');
173
- pendingPublish = {
174
- token,
175
- name: name.trim(),
176
- entries_count: collectedEntries.length,
85
+ // Run sensitive data scan
86
+ const sensitiveFindings = scanForSensitiveData(allEntries);
87
+ // Group entries by folder/topic
88
+ const groups = {};
89
+ for (const entry of allEntries) {
90
+ const rel = path.relative(getLocalPath(getLibraryPath()), entry.originalPath);
91
+ const folder = path.dirname(rel);
92
+ const key = folder === '.' ? 'root' : folder;
93
+ if (!groups[key])
94
+ groups[key] = [];
95
+ groups[key].push(entry.title);
96
+ }
97
+ // Build topic breakdown
98
+ const topicLines = Object.entries(groups)
99
+ .sort((a, b) => b[1].length - a[1].length)
100
+ .map(([folder, titles]) => ` ${folder}/ (${titles.length} entries)`)
101
+ .join('\n');
102
+ // Build warnings
103
+ const warnings = [];
104
+ if (sensitiveFindings.length > 0) {
105
+ warnings.push(`\n⚠️ SENSITIVE DATA found in ${sensitiveFindings.length} entry(s):`);
106
+ for (const f of sensitiveFindings) {
107
+ warnings.push(` ⚠ "${f.entry}": ${f.matches.join(', ')}`);
108
+ }
109
+ warnings.push('\nThese entries should be cleaned up before publishing.');
110
+ }
111
+ // Save state
112
+ wizardState = {
113
+ step: 'select_entries',
114
+ allEntries,
115
+ sensitiveWarnings: warnings.length > 0 ? warnings : undefined,
177
116
  created: Date.now(),
178
117
  };
179
118
  return {
180
119
  success: true,
181
- preview: true,
182
- message: `Preview of "${name.trim()}" - NOT published yet.\n\n⚠️ Show this to the user and ask for confirmation before publishing.`,
183
- publish_token: token,
184
- summary: {
185
- name: name.trim(),
186
- pricing: { type: pricing.type, display: pricingDisplay },
187
- entries_count: collectedEntries.length,
188
- entries: collectedEntries.map(e => ({
189
- title: e.title,
190
- file: path.basename(e.originalPath),
191
- })),
192
- },
193
- next_steps: 'Show preview to user. After they confirm, call library_publish() again with the publish_token to publish.',
194
- };
195
- }
196
- // ========================================================================
197
- // PUBLISH TOKEN VALIDATION
198
- // Cannot publish without a valid token from preview
199
- // ========================================================================
200
- if (!publish_token) {
201
- return {
202
- success: false,
203
- message: '🚫 Publishing requires a publish_token from a preview.\n\nYou must call library_publish({ preview: true, ... }) first, show the preview to the user, get their confirmation, then call again with the publish_token.\n\nThis is a safety measure to prevent accidental publishing.',
204
- };
205
- }
206
- if (!pendingPublish || pendingPublish.token !== publish_token) {
207
- return {
208
- success: false,
209
- message: '🚫 Invalid or expired publish_token. Run a new preview first with library_publish({ preview: true, ... }).',
210
- };
211
- }
212
- if (Date.now() - pendingPublish.created > TOKEN_EXPIRY_MS) {
213
- pendingPublish = null;
214
- return {
215
- success: false,
216
- message: '🚫 Publish token expired (5 minute limit). Run a new preview first.',
217
- };
218
- }
219
- // Token is valid — consume it (single use)
220
- pendingPublish = null;
221
- // Validate consumption type (required for actual publish)
222
- if (!consumption) {
223
- return {
224
- success: false,
225
- message: 'Consumption type required. Choose how buyers access your content:',
226
- options: {
227
- inline: 'Content returned in API responses (best for small entries)',
228
- reference: 'README + pointers to entries (best for larger books)',
229
- download: 'Download to local library (only for free/open books)',
230
- },
231
- };
232
- }
233
- if (!['inline', 'reference', 'download'].includes(consumption)) {
234
- return {
235
- success: false,
236
- message: 'Invalid consumption type. Must be: inline, reference, or download',
120
+ step: 'select_entries',
121
+ message: `📚 Found ${allEntries.length} entries in your library.\n\nTopics:\n${topicLines}${warnings.length > 0 ? '\n' + warnings.join('\n') : ''}`,
122
+ entries_count: allEntries.length,
123
+ ask_user: 'Which entries do you want to include? Say "all" or list specific ones to exclude.',
237
124
  };
238
125
  }
239
- if (consumption === 'download' && pricing.type !== 'open') {
240
- return {
241
- success: false,
242
- message: 'Download is only for free books. Paid content uses inline or reference.',
243
- next_steps: "Use pricing.type: 'open' for download, or consumption: 'inline'/'reference' for paid.",
244
- };
245
- }
246
- // Validate attestation (required for actual publish)
247
- if (!attestation) {
248
- return {
249
- success: false,
250
- message: 'Attestation required. Please confirm:',
251
- required: {
252
- original_work: 'This is my original work or I have rights to publish',
253
- no_secrets: 'Contains no secrets, credentials, or sensitive data',
254
- terms_accepted: 'I accept the Telvok library terms',
255
- },
256
- };
257
- }
258
- const failedAttestations = [];
259
- if (!attestation.original_work)
260
- failedAttestations.push('original_work');
261
- if (!attestation.no_secrets)
262
- failedAttestations.push('no_secrets');
263
- if (!attestation.terms_accepted)
264
- failedAttestations.push('terms_accepted');
265
- if (failedAttestations.length > 0) {
266
- return {
267
- success: false,
268
- message: `All attestation fields must be true to publish. Failed: ${failedAttestations.join(', ')}`,
269
- };
270
- }
271
- // Check authentication
272
- const apiKey = await loadApiKey();
273
- if (!apiKey) {
126
+ // ======================================================================
127
+ // STEP 2: Select entries → Show pricing options
128
+ // ======================================================================
129
+ if (wizardState.step === 'select_entries') {
130
+ if (!input.entries || input.entries.length === 0) {
131
+ return {
132
+ success: false,
133
+ step: 'select_entries',
134
+ message: 'Waiting for entry selection. Ask the user which entries to include.',
135
+ ask_user: 'Which entries do you want to include? Say "all" or list specific ones.',
136
+ };
137
+ }
138
+ let selected;
139
+ if (input.entries.length === 1 && input.entries[0].toLowerCase() === 'all') {
140
+ selected = [...wizardState.allEntries];
141
+ }
142
+ else {
143
+ selected = wizardState.allEntries.filter(e => {
144
+ const filename = path.basename(e.originalPath);
145
+ return input.entries.some(f => filename === f ||
146
+ filename === f + '.md' ||
147
+ e.originalPath.endsWith(f) ||
148
+ e.originalPath.endsWith(f + '.md') ||
149
+ e.title.toLowerCase().includes(f.toLowerCase()));
150
+ });
151
+ if (selected.length === 0) {
152
+ return {
153
+ success: false,
154
+ step: 'select_entries',
155
+ message: 'No entries matched your selection. Try again with different names or say "all".',
156
+ };
157
+ }
158
+ }
159
+ // Re-check sensitive data on selected entries only
160
+ const sensitiveFindings = scanForSensitiveData(selected);
161
+ if (sensitiveFindings.length > 0) {
162
+ const warnings = sensitiveFindings.map(f => ` ⚠ "${f.entry}": ${f.matches.join(', ')}`).join('\n');
163
+ return {
164
+ success: false,
165
+ step: 'select_entries',
166
+ message: `🚫 Cannot proceed — sensitive data detected in selected entries:\n${warnings}\n\nClean up these entries with record() or delete() first, then start over with library_publish().`,
167
+ };
168
+ }
169
+ wizardState.selectedEntries = selected;
170
+ wizardState.step = 'set_pricing';
274
171
  return {
275
- success: false,
276
- message: 'Not authenticated. Run auth({ action: "login" }) to connect your Telvok account first.',
172
+ success: true,
173
+ step: 'set_pricing',
174
+ message: `✓ ${selected.length} entries selected.\n\nChoose a pricing model:\n\n 📖 open — Free. Anyone can download to their local library.\n 💰 one_time — One-time purchase. You set the price (min $1). Cloud-only access. 20% platform fee.\n 🔄 subscription — Monthly subscription. You set the price (min $1/mo). Cloud-only, always latest. 20% platform fee.`,
175
+ selected_count: selected.length,
176
+ ask_user: 'Which pricing model? If paid, what price?',
277
177
  };
278
178
  }
279
- // Format entries for API
280
- const apiEntries = collectedEntries.map(e => ({
281
- title: e.title,
282
- content: e.content,
283
- intent: e.intent,
284
- context: e.context,
285
- reasoning: e.reasoning,
286
- example: e.example,
287
- }));
288
- // Build request body
289
- const requestBody = {
290
- name: name.trim(),
291
- description: description?.trim(),
292
- pricing,
293
- consumption,
294
- entries: apiEntries,
295
- tags: tags || [],
296
- license_type: license || 'personal',
297
- attestation,
298
- };
299
- try {
300
- const response = await fetch(`${TELVOK_API_URL}/api/publish`, {
301
- method: 'POST',
302
- headers: {
303
- 'Authorization': `Bearer ${apiKey}`,
304
- 'Content-Type': 'application/json',
305
- },
306
- body: JSON.stringify(requestBody),
307
- });
308
- const data = await response.json();
309
- if (!response.ok) {
310
- // Handle Stripe Connect requirement
311
- if (data.error === 'stripe_connect_required') {
179
+ // ======================================================================
180
+ // STEP 3: Set pricing → Ask for name/description
181
+ // ======================================================================
182
+ if (wizardState.step === 'set_pricing') {
183
+ if (!input.pricing || !input.pricing.type) {
184
+ return {
185
+ success: false,
186
+ step: 'set_pricing',
187
+ message: 'Waiting for pricing selection. Ask the user which pricing model they want.',
188
+ ask_user: 'Which pricing model? open (free), one_time, or subscription?',
189
+ };
190
+ }
191
+ if (!['open', 'one_time', 'subscription'].includes(input.pricing.type)) {
192
+ return {
193
+ success: false,
194
+ step: 'set_pricing',
195
+ message: 'Invalid pricing type. Must be: open, one_time, or subscription.',
196
+ };
197
+ }
198
+ if (input.pricing.type !== 'open') {
199
+ if (!input.pricing.price_cents || input.pricing.price_cents < 100) {
312
200
  return {
313
201
  success: false,
314
- message: `Stripe Connect required to sell paid content.\n\nComplete setup at: ${data.setup_url}`,
315
- setup_url: data.setup_url,
202
+ step: 'set_pricing',
203
+ message: 'Paid books require a price of at least $1.00 (100 cents).',
204
+ ask_user: 'What price? (in dollars, e.g. $5 = 500 cents)',
316
205
  };
317
206
  }
318
- // Handle validation errors
319
- if (data.error === 'validation_error') {
320
- const details = Object.entries(data.details || {})
321
- .map(([k, v]) => ` - ${k}: ${v}`)
322
- .join('\n');
207
+ if (input.pricing.price_cents > 100000) {
323
208
  return {
324
209
  success: false,
325
- message: `Validation failed:\n${details}`,
210
+ step: 'set_pricing',
211
+ message: 'Maximum price is $1000.00.',
326
212
  };
327
213
  }
214
+ }
215
+ // Set consumption based on pricing
216
+ let consumption = input.consumption;
217
+ if (input.pricing.type === 'open') {
218
+ consumption = 'download';
219
+ }
220
+ else if (!consumption || !['inline', 'reference'].includes(consumption)) {
221
+ consumption = 'inline'; // default for paid
222
+ }
223
+ wizardState.pricing = input.pricing;
224
+ wizardState.consumption = consumption;
225
+ wizardState.step = 'set_details';
226
+ const priceDisplay = input.pricing.type === 'open'
227
+ ? 'Free (download)'
228
+ : `$${(input.pricing.price_cents / 100).toFixed(2)}/${input.pricing.type === 'subscription' ? 'mo' : 'once'} (20% platform fee)`;
229
+ return {
230
+ success: true,
231
+ step: 'set_details',
232
+ message: `✓ Pricing: ${priceDisplay}\n\nNow give your book a name and description.`,
233
+ ask_user: 'What do you want to call this book? And a short description (optional, max 500 chars)?',
234
+ };
235
+ }
236
+ // ======================================================================
237
+ // STEP 4: Set details → Final confirmation
238
+ // ======================================================================
239
+ if (wizardState.step === 'set_details') {
240
+ if (!input.name || input.name.trim().length < 3) {
328
241
  return {
329
242
  success: false,
330
- message: data.error || `Publish failed: HTTP ${response.status}`,
243
+ step: 'set_details',
244
+ message: 'Book name required (at least 3 characters).',
245
+ ask_user: 'What do you want to call this book?',
331
246
  };
332
247
  }
248
+ if (input.name.trim().length > 100) {
249
+ return {
250
+ success: false,
251
+ step: 'set_details',
252
+ message: 'Book name must be 100 characters or less.',
253
+ };
254
+ }
255
+ if (input.description && input.description.length > 500) {
256
+ return {
257
+ success: false,
258
+ step: 'set_details',
259
+ message: 'Description must be 500 characters or less.',
260
+ };
261
+ }
262
+ wizardState.name = input.name.trim();
263
+ wizardState.description = input.description?.trim();
264
+ wizardState.tags = input.tags;
265
+ wizardState.license = input.license || 'personal';
266
+ wizardState.step = 'confirm';
267
+ const priceDisplay = wizardState.pricing.type === 'open'
268
+ ? 'Free (download)'
269
+ : `$${(wizardState.pricing.price_cents / 100).toFixed(2)}/${wizardState.pricing.type === 'subscription' ? 'mo' : 'once'}`;
333
270
  return {
334
271
  success: true,
335
- message: data.message || `Published "${data.book?.name}" with ${data.entries_count} entries`,
336
- book: data.book,
337
- entries_count: data.entries_count,
272
+ step: 'confirm',
273
+ message: `📋 PUBLISH SUMMARY\n\n Name: ${wizardState.name}\n Entries: ${wizardState.selectedEntries.length}\n Pricing: ${priceDisplay}${wizardState.description ? `\n Description: ${wizardState.description}` : ''}${wizardState.tags?.length ? `\n Tags: ${wizardState.tags.join(', ')}` : ''}`,
274
+ ask_user: 'Show this summary to the user. Ask: "Publish this? (yes/no)"',
338
275
  };
339
276
  }
340
- catch (error) {
341
- const message = error instanceof Error ? error.message : String(error);
342
- throw new Error(`Publish failed: ${message}`);
277
+ // ======================================================================
278
+ // STEP 5: User confirms Publish
279
+ // ======================================================================
280
+ if (wizardState.step === 'confirm') {
281
+ if (!input.confirm) {
282
+ wizardState = null;
283
+ return {
284
+ success: false,
285
+ message: 'Publish cancelled. Run library_publish() to start over.',
286
+ };
287
+ }
288
+ return await executePublish(wizardState);
343
289
  }
290
+ return { success: false, message: 'Unknown state. Run library_publish() to start over.' };
344
291
  },
345
292
  };
346
293
  // ============================================================================
294
+ // Execute Publish — called after confirmation
295
+ // ============================================================================
296
+ async function executePublish(state) {
297
+ const apiKey = await loadApiKey();
298
+ if (!apiKey) {
299
+ wizardState = null;
300
+ return {
301
+ success: false,
302
+ message: 'Not authenticated. Run auth({ action: "login" }) first.',
303
+ };
304
+ }
305
+ const apiEntries = state.selectedEntries.map(e => ({
306
+ title: e.title,
307
+ content: e.content,
308
+ intent: e.intent,
309
+ context: e.context,
310
+ reasoning: e.reasoning,
311
+ example: e.example,
312
+ }));
313
+ const requestBody = {
314
+ name: state.name,
315
+ description: state.description,
316
+ pricing: state.pricing,
317
+ consumption: state.consumption,
318
+ entries: apiEntries,
319
+ tags: state.tags || [],
320
+ license_type: state.license || 'personal',
321
+ attestation: {
322
+ original_work: true,
323
+ no_secrets: true,
324
+ terms_accepted: true,
325
+ },
326
+ };
327
+ try {
328
+ const response = await fetch(`${TELVOK_API_URL}/api/publish`, {
329
+ method: 'POST',
330
+ headers: {
331
+ 'Authorization': `Bearer ${apiKey}`,
332
+ 'Content-Type': 'application/json',
333
+ },
334
+ body: JSON.stringify(requestBody),
335
+ });
336
+ const data = await response.json();
337
+ wizardState = null; // Clear state after attempt
338
+ if (!response.ok) {
339
+ if (data.error === 'stripe_connect_required') {
340
+ return {
341
+ success: false,
342
+ message: `Stripe Connect required to sell paid content.\n\nComplete setup at: ${data.setup_url}`,
343
+ setup_url: data.setup_url,
344
+ };
345
+ }
346
+ if (data.error === 'validation_error') {
347
+ const details = Object.entries(data.details || {})
348
+ .map(([k, v]) => ` - ${k}: ${v}`)
349
+ .join('\n');
350
+ return { success: false, message: `Validation failed:\n${details}` };
351
+ }
352
+ return { success: false, message: data.error || `Publish failed: HTTP ${response.status}` };
353
+ }
354
+ return {
355
+ success: true,
356
+ message: `✅ Published "${data.book?.name}" with ${data.entries_count} entries\n\n🔗 ${data.book?.url}`,
357
+ book: data.book,
358
+ entries_count: data.entries_count,
359
+ };
360
+ }
361
+ catch (error) {
362
+ wizardState = null;
363
+ const message = error instanceof Error ? error.message : String(error);
364
+ throw new Error(`Publish failed: ${message}`);
365
+ }
366
+ }
367
+ // ============================================================================
347
368
  // Entry Collection
348
369
  // ============================================================================
349
370
  async function collectLocalEntries(filter) {
@@ -354,7 +375,6 @@ async function collectLocalEntries(filter) {
354
375
  const files = await glob(path.join(localPath, '**/*.md'), { nodir: true });
355
376
  for (const filePath of files) {
356
377
  const filename = path.basename(filePath);
357
- // If filter specified, only include matching files
358
378
  if (filter && filter.length > 0) {
359
379
  const matchesFilter = filter.some(f => filename === f ||
360
380
  filename === f + '.md' ||
@@ -367,10 +387,7 @@ async function collectLocalEntries(filter) {
367
387
  const content = await fs.readFile(filePath, 'utf-8');
368
388
  const parsed = parseEntryFile(content, filePath);
369
389
  if (parsed) {
370
- entries.push({
371
- ...parsed,
372
- originalPath: filePath,
373
- });
390
+ entries.push({ ...parsed, originalPath: filePath });
374
391
  }
375
392
  }
376
393
  catch {
@@ -388,7 +405,6 @@ function parseEntryFile(content, filePath) {
388
405
  const trimmedBody = body.trim();
389
406
  if (!trimmedBody)
390
407
  return null;
391
- // Extract title from frontmatter, H1, or filename
392
408
  let title = frontmatter.title;
393
409
  if (!title) {
394
410
  const headingMatch = trimmedBody.match(/^#\s+(.+)$/m);
@@ -396,13 +412,11 @@ function parseEntryFile(content, filePath) {
396
412
  title = headingMatch[1].trim();
397
413
  }
398
414
  else {
399
- // Use filename as title, converting hyphens to spaces
400
415
  title = path.basename(filePath, '.md')
401
416
  .replace(/-/g, ' ')
402
417
  .replace(/\b\w/g, l => l.toUpperCase());
403
418
  }
404
419
  }
405
- // Extract sections from body
406
420
  const sections = extractSections(trimmedBody);
407
421
  return {
408
422
  title,
@@ -414,30 +428,21 @@ function parseEntryFile(content, filePath) {
414
428
  };
415
429
  }
416
430
  function extractSections(body) {
417
- const result = {
418
- main: body,
419
- };
420
- // Find ## Reasoning section
431
+ const result = { main: body };
421
432
  const reasoningMatch = body.match(/##\s*Reasoning\s*\n([\s\S]*?)(?=##|$)/i);
422
- if (reasoningMatch) {
433
+ if (reasoningMatch)
423
434
  result.reasoning = reasoningMatch[1].trim();
424
- }
425
- // Find ## Example section
426
435
  const exampleMatch = body.match(/##\s*Example\s*\n([\s\S]*?)(?=##|$)/i);
427
- if (exampleMatch) {
436
+ if (exampleMatch)
428
437
  result.example = exampleMatch[1].trim();
429
- }
430
- // Main content is everything after title until first ## section
431
438
  const mainMatch = body.match(/^#\s+.+\n\n?([\s\S]*?)(?=##|$)/);
432
439
  if (mainMatch) {
433
440
  result.main = mainMatch[1].trim();
434
441
  }
435
442
  else {
436
- // If no H1 header, take content before first ## section
437
443
  const beforeSections = body.match(/^([\s\S]*?)(?=##)/);
438
- if (beforeSections) {
444
+ if (beforeSections)
439
445
  result.main = beforeSections[1].trim();
440
- }
441
446
  }
442
447
  return result;
443
448
  }
@@ -9,11 +9,7 @@ export declare const libraryUnpublishTool: {
9
9
  type: string;
10
10
  description: string;
11
11
  };
12
- preview: {
13
- type: string;
14
- description: string;
15
- };
16
- unpublish_token: {
12
+ confirm: {
17
13
  type: string;
18
14
  description: string;
19
15
  };
@@ -21,31 +17,23 @@ export declare const libraryUnpublishTool: {
21
17
  required: string[];
22
18
  };
23
19
  handler(args: unknown): Promise<{
20
+ success: boolean;
21
+ message: any;
22
+ book?: undefined;
23
+ } | {
24
+ success: boolean;
25
+ message: any;
26
+ book: any;
27
+ } | {
24
28
  success: boolean;
25
29
  preview: boolean;
26
30
  message: string;
27
- unpublish_token: string;
31
+ ask_user: string;
28
32
  book: {
29
- slug: any;
33
+ slug: string;
30
34
  name: any;
31
35
  entries_count: any;
32
36
  pricing: any;
33
- url: any;
34
37
  };
35
- next_steps: string;
36
- } | {
37
- success: boolean;
38
- message: any;
39
- preview?: undefined;
40
- unpublish_token?: undefined;
41
- book?: undefined;
42
- next_steps?: undefined;
43
- } | {
44
- success: boolean;
45
- message: any;
46
- book: any;
47
- preview?: undefined;
48
- unpublish_token?: undefined;
49
- next_steps?: undefined;
50
38
  }>;
51
39
  };
@@ -1,12 +1,20 @@
1
1
  // ============================================================================
2
2
  // Marketplace Unpublish Tool
3
- // Remove a published book from Telvok library
3
+ // Two-step: preview user confirms delete
4
4
  // ============================================================================
5
- import * as crypto from 'crypto';
6
5
  import { loadApiKey } from './auth.js';
7
6
  const TELVOK_API_URL = process.env.TELVOK_API_URL || 'https://telvok.com';
8
- const TOKEN_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
9
- let pendingUnpublish = null;
7
+ // ============================================================================
8
+ // Pending State — preview stores slug, expires 5 min
9
+ // ============================================================================
10
+ let pendingSlug = null;
11
+ let pendingCreated = 0;
12
+ const EXPIRY_MS = 5 * 60 * 1000;
13
+ function clearExpired() {
14
+ if (pendingSlug && Date.now() - pendingCreated > EXPIRY_MS) {
15
+ pendingSlug = null;
16
+ }
17
+ }
10
18
  // ============================================================================
11
19
  // Tool Definition
12
20
  // ============================================================================
@@ -15,23 +23,18 @@ export const libraryUnpublishTool = {
15
23
  title: 'Unpublish Book',
16
24
  description: `Remove a published book from Telvok marketplace.
17
25
 
18
- ⚠️ TWO-STEP UNPUBLISH FLOW (MANDATORY):
26
+ TWO-STEP FLOW:
19
27
 
20
- Step 1: ALWAYS call with preview: true first. Shows what will be deleted
21
- and returns an unpublish_token. Show the preview to the user and ASK FOR CONFIRMATION.
28
+ Step 1: Call with just slug. Shows book details and what will be deleted.
29
+ Step 2: Call again with slug + confirm: true ONLY after the user says yes.
22
30
 
23
- Step 2: ONLY after the user explicitly confirms, call again with the unpublish_token
24
- from the preview response. Unpublishing WITHOUT a valid token will be rejected.
31
+ DO NOT set confirm: true without the user explicitly saying yes.
32
+ Show the preview and ask "Delete this book? (yes/no)" first.
25
33
 
26
34
  RESTRICTIONS:
27
35
  - Cannot unpublish books with active purchases
28
36
  - Deletion is PERMANENT — all entries are removed from marketplace
29
37
 
30
- TRIGGER PATTERNS:
31
- - "Unpublish my book" → library_unpublish({ slug: "...", preview: true })
32
- - "Remove from marketplace" → library_unpublish({ slug: "...", preview: true })
33
- - User says "yes, delete it" → library_unpublish({ slug: "...", unpublish_token: "<token>" })
34
-
35
38
  Use my_books() first to see your published books and their slugs.`,
36
39
  inputSchema: {
37
40
  type: 'object',
@@ -40,122 +43,89 @@ Use my_books() first to see your published books and their slugs.`,
40
43
  type: 'string',
41
44
  description: 'Book slug (from my_books output)',
42
45
  },
43
- preview: {
46
+ confirm: {
44
47
  type: 'boolean',
45
- description: 'If true, show what would be deleted without deleting. Returns an unpublish_token.',
46
- },
47
- unpublish_token: {
48
- type: 'string',
49
- description: 'Token from preview response. Required to actually unpublish. Single-use, expires in 5 minutes.',
48
+ description: 'Set to true ONLY after showing preview to user and they say yes.',
50
49
  },
51
50
  },
52
51
  required: ['slug'],
53
52
  },
54
53
  async handler(args) {
55
- const { slug, preview, unpublish_token } = (args || {});
56
- // Validate slug
54
+ const { slug, confirm } = (args || {});
55
+ clearExpired();
57
56
  if (!slug || typeof slug !== 'string') {
58
57
  return { success: false, message: 'slug is required. Use my_books() to see your published books.' };
59
58
  }
60
- // Load API key
61
59
  const apiKey = await loadApiKey();
62
60
  if (!apiKey) {
63
- return {
64
- success: false,
65
- message: 'Not authenticated. Run auth({ action: "login" }) first.',
66
- };
61
+ return { success: false, message: 'Not authenticated. Run auth({ action: "login" }) first.' };
67
62
  }
68
63
  // ========================================================================
69
- // PREVIEW MODEshow what will be deleted, generate token
64
+ // CONFIRM STEPuser said yes
70
65
  // ========================================================================
71
- if (preview) {
72
- // Fetch book details via my-books to confirm it exists and we own it
73
- const res = await fetch(`${TELVOK_API_URL}/api/my-books`, {
74
- headers: { 'Authorization': `Bearer ${apiKey}` },
75
- });
76
- if (!res.ok) {
77
- return { success: false, message: `Failed to fetch books: ${res.status}` };
78
- }
79
- const data = await res.json();
80
- const book = data.published?.find((b) => b.slug === slug);
81
- if (!book) {
66
+ if (confirm) {
67
+ if (pendingSlug !== slug) {
82
68
  return {
83
69
  success: false,
84
- message: `No published book found with slug "${slug}". Use my_books() to see your books.`,
70
+ message: 'No pending unpublish for this slug. Call library_unpublish({ slug }) first to preview.',
85
71
  };
86
72
  }
87
- // Generate token
88
- const token = crypto.randomBytes(16).toString('hex');
89
- pendingUnpublish = { token, slug, created: Date.now() };
90
- return {
91
- success: true,
92
- preview: true,
93
- message: `Preview of unpublish — NOT deleted yet.\n\n⚠️ Show this to the user and ask for confirmation before unpublishing.`,
94
- unpublish_token: token,
95
- book: {
96
- slug: book.slug,
97
- name: book.name,
98
- entries_count: book.entries_count,
99
- pricing: book.pricing,
100
- url: book.url,
101
- },
102
- next_steps: 'Show preview to user. After they confirm, call library_unpublish() again with the unpublish_token to delete.',
103
- };
73
+ pendingSlug = null;
74
+ return await executeUnpublish(slug, apiKey);
104
75
  }
105
76
  // ========================================================================
106
- // EXECUTE MODEvalidate token, call API to delete
77
+ // PREVIEW STEPfetch book details, show to user
107
78
  // ========================================================================
108
- // Token required
109
- if (!unpublish_token) {
110
- return {
111
- success: false,
112
- message: '🚫 Unpublishing requires an unpublish_token. Call with preview: true first to get one.',
113
- };
114
- }
115
- // Validate token
116
- if (!pendingUnpublish || pendingUnpublish.token !== unpublish_token) {
117
- return {
118
- success: false,
119
- message: '🚫 Invalid or expired unpublish_token. Run a new preview to get a fresh token.',
120
- };
121
- }
122
- // Check expiry
123
- if (Date.now() - pendingUnpublish.created > TOKEN_EXPIRY_MS) {
124
- pendingUnpublish = null;
125
- return {
126
- success: false,
127
- message: '🚫 Unpublish token expired (5 minute limit). Run a new preview.',
128
- };
129
- }
130
- // Check slug matches
131
- if (pendingUnpublish.slug !== slug) {
132
- return {
133
- success: false,
134
- message: `🚫 Token was generated for slug "${pendingUnpublish.slug}", not "${slug}". Run a new preview.`,
135
- };
136
- }
137
- // Consume token (single use)
138
- pendingUnpublish = null;
139
- // Call API
140
- const res = await fetch(`${TELVOK_API_URL}/api/publish`, {
141
- method: 'DELETE',
142
- headers: {
143
- 'Authorization': `Bearer ${apiKey}`,
144
- 'Content-Type': 'application/json',
145
- },
146
- body: JSON.stringify({ slug }),
79
+ const res = await fetch(`${TELVOK_API_URL}/api/my-books`, {
80
+ headers: { 'Authorization': `Bearer ${apiKey}` },
147
81
  });
148
- const data = await res.json();
149
82
  if (!res.ok) {
83
+ return { success: false, message: `Failed to fetch books: ${res.status}` };
84
+ }
85
+ const data = await res.json();
86
+ const book = data.published?.find((b) => b.slug === slug);
87
+ if (!book) {
150
88
  return {
151
89
  success: false,
152
- message: data.message || data.error || `Unpublish failed: ${res.status}`,
90
+ message: `No published book found with slug "${slug}". Use my_books() to see your books.`,
153
91
  };
154
92
  }
93
+ const bookName = book.name || slug;
94
+ const entriesCount = book.entries_count || book.entry_count || 0;
95
+ const pricing = book.pricing_type || book.pricing || 'unknown';
96
+ pendingSlug = slug;
97
+ pendingCreated = Date.now();
155
98
  return {
156
99
  success: true,
157
- message: data.message || `Unpublished "${slug}" from marketplace`,
158
- book: data.book,
100
+ preview: true,
101
+ message: `⚠️ UNPUBLISH PREVIEW\n\n Book: ${bookName}\n Slug: ${slug}\n Entries: ${entriesCount}\n Pricing: ${pricing}\n\n This action is PERMANENT.`,
102
+ ask_user: 'Show this to the user. Ask: "Delete this book from the marketplace? (yes/no)"',
103
+ book: { slug, name: bookName, entries_count: entriesCount, pricing },
159
104
  };
160
105
  },
161
106
  };
107
+ // ============================================================================
108
+ // Execute Unpublish — called after confirmation
109
+ // ============================================================================
110
+ async function executeUnpublish(slug, apiKey) {
111
+ const res = await fetch(`${TELVOK_API_URL}/api/publish`, {
112
+ method: 'DELETE',
113
+ headers: {
114
+ 'Authorization': `Bearer ${apiKey}`,
115
+ 'Content-Type': 'application/json',
116
+ },
117
+ body: JSON.stringify({ slug }),
118
+ });
119
+ const data = await res.json();
120
+ if (!res.ok) {
121
+ return {
122
+ success: false,
123
+ message: data.message || data.error || `Unpublish failed: ${res.status}`,
124
+ };
125
+ }
126
+ return {
127
+ success: true,
128
+ message: data.message || `Unpublished "${slug}" from marketplace`,
129
+ book: data.book,
130
+ };
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telvok/librarian-mcp",
3
- "version": "2.3.2",
3
+ "version": "2.4.1",
4
4
  "description": "Knowledge capture MCP server - remember what you learn with AI",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",