sapiens-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ import { z } from "zod";
2
+ import { convexAction, convexMutation, convexQuery, getSessionToken, } from "../convexClient.js";
3
+ const FORMATS = [
4
+ "tirinha",
5
+ "carrossel_ig",
6
+ "post_social",
7
+ "musica",
8
+ "mega_grafico",
9
+ "post_linkedin",
10
+ "post_twitter",
11
+ "post_threads",
12
+ "shorts_yt",
13
+ "cena_visual",
14
+ "video_yt",
15
+ ];
16
+ const STATUSES = ["draft", "ready", "finalized"];
17
+ export const pipelineSchema = z.object({
18
+ action: z.enum([
19
+ "list_sources",
20
+ "list_articles",
21
+ "get_source",
22
+ "get_production",
23
+ "list_versions",
24
+ "add_article_as_source",
25
+ "create_draft_article_and_source",
26
+ "create_production",
27
+ "update_production",
28
+ "finalize_production",
29
+ "remove_production",
30
+ "remove_source",
31
+ "set_source_done",
32
+ "update_source_notes",
33
+ "restore_version",
34
+ "set_publishable_title",
35
+ "backfill_via",
36
+ "propose_mega_grafico_plan",
37
+ "run_mega_grafico_full",
38
+ ]),
39
+ // propose_mega_grafico_plan / run_mega_grafico_full
40
+ withHelen: z
41
+ .boolean()
42
+ .optional()
43
+ .describe("Se TRUE, plano reserva 1 painel pra Helen Ailith interagindo com o tema (cartoon editorial). Se FALSE, poster 100% diagramático sem figura humana. Pergunte ao user antes de definir."),
44
+ // run_mega_grafico_full
45
+ skipFinalize: z
46
+ .boolean()
47
+ .optional()
48
+ .describe("Se TRUE, gera plano + imagem + branding + salva payload mas NÃO cria publishable (deixa production em 'ready' pro admin revisar). Default false."),
49
+ skipBranding: z
50
+ .boolean()
51
+ .optional()
52
+ .describe("Se TRUE, pula o composite do selo Sapiens + tEXt chunks. Use só quando o Convex storage está fora ou pra debug. Default false."),
53
+ templateSlug: z
54
+ .string()
55
+ .optional()
56
+ .describe("Slug do template de receita (admin edita em /dashboard/admin/image-templates). Se omitido, usa o template marcado isDefault=true pro formato. Ex: 'mega-grafico-blueprint-v1'."),
57
+ specOverride: z
58
+ .object({
59
+ model: z.string().optional(),
60
+ aspectRatio: z.string().optional(),
61
+ size: z.string().optional(),
62
+ })
63
+ .optional()
64
+ .describe("Override de model/aspectRatio/size sobre os defaults do template. Cada um valida contra o allowedX do template; se sair do whitelist, action throws."),
65
+ // list_articles
66
+ includeDrafts: z.boolean().optional(),
67
+ includeArchived: z.boolean().optional(),
68
+ onlyAvailable: z
69
+ .boolean()
70
+ .optional()
71
+ .describe("Se true, só artigos que ainda NÃO foram virados em source"),
72
+ // por id
73
+ sourceId: z.string().optional(),
74
+ productionId: z.string().optional(),
75
+ publishableId: z.string().optional(),
76
+ articleId: z.string().optional(),
77
+ // create / add
78
+ format: z.enum(FORMATS).optional(),
79
+ payload: z.any().optional().describe("Payload livre por formato"),
80
+ status: z.enum(STATUSES).optional(),
81
+ notes: z.string().optional(),
82
+ // create_draft_article_and_source
83
+ title: z.string().optional(),
84
+ slug: z.string().optional(),
85
+ excerpt: z.string().optional(),
86
+ tldr: z.string().optional(),
87
+ content: z.string().optional(),
88
+ category: z.string().optional(),
89
+ tags: z.array(z.string()).optional(),
90
+ thumbnailUrl: z.string().optional(),
91
+ // finalize_production
92
+ assets: z
93
+ .array(z.object({
94
+ kind: z.string(),
95
+ url: z.string().optional(),
96
+ text: z.string().optional(),
97
+ meta: z.any().optional(),
98
+ }))
99
+ .optional(),
100
+ caption: z.string().optional(),
101
+ hashtags: z.array(z.string()).optional(),
102
+ // set_source_done
103
+ isDone: z.boolean().optional(),
104
+ // backfill_via
105
+ sinceCreatedAt: z.number().optional(),
106
+ beforeCreatedAt: z.number().optional(),
107
+ via: z
108
+ .string()
109
+ .optional()
110
+ .describe("Ex: 'claude-mcp', 'modo-antigo', 'manual'"),
111
+ dryRun: z.boolean().optional(),
112
+ });
113
+ function need(value, name) {
114
+ if (value === undefined || value === null) {
115
+ throw new Error(`Faltando arg "${name}" pra essa action.`);
116
+ }
117
+ return value;
118
+ }
119
+ export async function pipeline(args) {
120
+ const sessionToken = getSessionToken();
121
+ switch (args.action) {
122
+ case "list_sources":
123
+ return await convexQuery("pipelineMcp:mcpListSources", { sessionToken });
124
+ case "list_articles":
125
+ return await convexQuery("pipelineMcp:mcpListArticles", {
126
+ sessionToken,
127
+ includeDrafts: args.includeDrafts,
128
+ includeArchived: args.includeArchived,
129
+ onlyAvailable: args.onlyAvailable,
130
+ });
131
+ case "get_source":
132
+ return await convexQuery("pipelineMcp:mcpGetSource", {
133
+ sessionToken,
134
+ sourceId: need(args.sourceId, "sourceId"),
135
+ });
136
+ case "get_production":
137
+ return await convexQuery("pipelineMcp:mcpGetProduction", {
138
+ sessionToken,
139
+ productionId: need(args.productionId, "productionId"),
140
+ });
141
+ case "list_versions":
142
+ return await convexQuery("pipelineMcp:mcpListPublishableVersions", {
143
+ sessionToken,
144
+ productionId: need(args.productionId, "productionId"),
145
+ });
146
+ case "add_article_as_source": {
147
+ const id = await convexMutation("pipelineMcp:mcpAddArticleAsSource", {
148
+ sessionToken,
149
+ articleId: need(args.articleId, "articleId"),
150
+ notes: args.notes,
151
+ });
152
+ return { sourceId: id };
153
+ }
154
+ case "create_draft_article_and_source": {
155
+ const result = await convexMutation("pipelineMcp:mcpCreateDraftArticleAndSource", {
156
+ sessionToken,
157
+ title: need(args.title, "title"),
158
+ slug: need(args.slug, "slug"),
159
+ excerpt: args.excerpt,
160
+ tldr: args.tldr,
161
+ content: need(args.content, "content"),
162
+ category: args.category,
163
+ tags: args.tags,
164
+ thumbnailUrl: args.thumbnailUrl,
165
+ });
166
+ return result;
167
+ }
168
+ case "create_production": {
169
+ const id = await convexMutation("pipelineMcp:mcpCreateProductionDraft", {
170
+ sessionToken,
171
+ sourceId: need(args.sourceId, "sourceId"),
172
+ format: need(args.format, "format"),
173
+ payload: args.payload,
174
+ });
175
+ return { productionId: id };
176
+ }
177
+ case "update_production":
178
+ await convexMutation("pipelineMcp:mcpUpdateProductionPayload", {
179
+ sessionToken,
180
+ productionId: need(args.productionId, "productionId"),
181
+ payload: need(args.payload, "payload"),
182
+ status: args.status,
183
+ });
184
+ return { ok: true };
185
+ case "finalize_production": {
186
+ const id = await convexMutation("pipelineMcp:mcpFinalizeProduction", {
187
+ sessionToken,
188
+ productionId: need(args.productionId, "productionId"),
189
+ assets: need(args.assets, "assets"),
190
+ caption: args.caption,
191
+ hashtags: args.hashtags,
192
+ title: args.title,
193
+ });
194
+ return { publishableId: id };
195
+ }
196
+ case "set_publishable_title":
197
+ await convexMutation("pipelineMcp:mcpSetPublishableTitle", {
198
+ sessionToken,
199
+ publishableId: need(args.publishableId, "publishableId"),
200
+ title: need(args.title, "title"),
201
+ });
202
+ return { ok: true };
203
+ case "backfill_via":
204
+ return await convexMutation("pipelineMcp:mcpBackfillVia", {
205
+ sessionToken,
206
+ sinceCreatedAt: args.sinceCreatedAt,
207
+ beforeCreatedAt: args.beforeCreatedAt,
208
+ via: need(args.via, "via"),
209
+ dryRun: args.dryRun,
210
+ });
211
+ case "remove_production":
212
+ await convexMutation("pipelineMcp:mcpRemoveProduction", {
213
+ sessionToken,
214
+ productionId: need(args.productionId, "productionId"),
215
+ });
216
+ return { ok: true };
217
+ case "remove_source":
218
+ await convexMutation("pipelineMcp:mcpRemoveSource", {
219
+ sessionToken,
220
+ sourceId: need(args.sourceId, "sourceId"),
221
+ });
222
+ return { ok: true };
223
+ case "set_source_done":
224
+ await convexMutation("pipelineMcp:mcpSetSourceDone", {
225
+ sessionToken,
226
+ sourceId: need(args.sourceId, "sourceId"),
227
+ isDone: need(args.isDone, "isDone"),
228
+ });
229
+ return { ok: true };
230
+ case "update_source_notes":
231
+ await convexMutation("pipelineMcp:mcpUpdateSourceNotes", {
232
+ sessionToken,
233
+ sourceId: need(args.sourceId, "sourceId"),
234
+ notes: need(args.notes, "notes"),
235
+ });
236
+ return { ok: true };
237
+ case "restore_version": {
238
+ const productionId = await convexMutation("pipelineMcp:mcpRestoreVersion", {
239
+ sessionToken,
240
+ publishableId: need(args.publishableId, "publishableId"),
241
+ });
242
+ return { productionId };
243
+ }
244
+ case "propose_mega_grafico_plan": {
245
+ // Gemini propõe um plano denso de mega gráfico a partir do source
246
+ // (artigo ou brief). Devolve fullPrompt + spec recomendada (vem do
247
+ // template) e referenceImageUrls se withHelen=true. templateSlug e
248
+ // specOverride respeitam o que o admin configurou em
249
+ // /dashboard/admin/image-templates.
250
+ // Use APENAS quando quiser controle granular (revisar plano antes de
251
+ // gerar imagem). Pra fluxo end-to-end, prefira run_mega_grafico_full
252
+ // que faz tudo numa chamada só.
253
+ const result = await convexAction("megaGraficoActions:mcpProposeMegaGraficoPlan", {
254
+ sessionToken,
255
+ sourceId: need(args.sourceId, "sourceId"),
256
+ withHelen: args.withHelen ?? false,
257
+ templateSlug: args.templateSlug,
258
+ specOverride: args.specOverride,
259
+ });
260
+ return result;
261
+ }
262
+ case "run_mega_grafico_full": {
263
+ // One-shot do fluxo mega gráfico inteiro: cria production (ou reusa
264
+ // se productionId passado), propõe plano com guard de qualidade (1
265
+ // retry se fraco), gera imagem via gpt-image-2-high, aplica selo
266
+ // Sapiens (logo + PNG tEXt chunks), salva payload, e finaliza como
267
+ // publishable (skipFinalize=true pula essa etapa).
268
+ //
269
+ // Custo: ~900 sinapses da geração de imagem + 0-100 sinapses Gemini
270
+ // text. Total ~900-1000.
271
+ //
272
+ // ANTES de chamar: pergunte ao user se withHelen=true (Helen
273
+ // interage com tema, ~15-25% do poster) ou false (poster 100%
274
+ // diagramático, sem humanos).
275
+ //
276
+ // Receita usada: o templateSlug do payload (admin edita em
277
+ // /dashboard/admin/image-templates). Sem templateSlug, usa o default.
278
+ // specOverride permite trocar model/aspect/size dentro do whitelist
279
+ // do template (ex: aspectRatio="1:1" pra IG, "16:9" pra og:image).
280
+ const result = await convexAction("megaGraficoRunner:mcpRunMegaGraficoFull", {
281
+ sessionToken,
282
+ sourceId: need(args.sourceId, "sourceId"),
283
+ withHelen: args.withHelen ?? false,
284
+ productionId: args.productionId,
285
+ skipFinalize: args.skipFinalize,
286
+ skipBranding: args.skipBranding,
287
+ caption: args.caption,
288
+ templateSlug: args.templateSlug,
289
+ specOverride: args.specOverride,
290
+ });
291
+ return result;
292
+ }
293
+ }
294
+ }
@@ -0,0 +1,175 @@
1
+ import { z } from "zod";
2
+ import { convexMutation, getSessionToken } from "../convexClient.js";
3
+ /**
4
+ * Publish curado pra Coluna Sapiens (quote) e Coluna Repertório (pop-article)
5
+ * via session token. Substitui:
6
+ * - apps/sapiens/scripts/sapiens-quote-publish.js
7
+ * - apps/sapiens/scripts/sapiens-pop-article-publish.js
8
+ *
9
+ * Antes esses scripts usavam setAdminAuth (CONVEX_ADMIN_KEY). Agora o MCP
10
+ * publica direto via `mcpExtras.mcpPublishQuote` / `mcpPublishPopArticle`,
11
+ * que valida o sessionToken e exige user admin. Mesma garantia de segurança,
12
+ * setup zero pro user (não precisa mais ter CONVEX_ADMIN_KEY no env).
13
+ *
14
+ * Sub-actions:
15
+ * - publish_quote: cria entry na Coluna Sapiens. Default status="draft"
16
+ * (admin revisa em /dashboard/admin/blog/sapiens-curation). publishNow=true
17
+ * pula direto pra published.
18
+ * - publish_pop: cria entry na Coluna Repertório (artigo longo com obra
19
+ * do acervo como lente). column="repertorio", format="pop-article".
20
+ */
21
+ const quoteImageRef = z.object({
22
+ url: z.string(),
23
+ alt: z.string(),
24
+ caption: z.string().optional(),
25
+ credit: z.string().optional(),
26
+ license: z.string().optional(),
27
+ });
28
+ const quotePayload = z.object({
29
+ text: z.string(),
30
+ author: z.string(),
31
+ authorWikidataId: z.string().optional(),
32
+ sourceWork: z.string().optional(),
33
+ sourceUrl: z.string().optional(),
34
+ year: z.number().optional(),
35
+ page: z.string().optional(),
36
+ originalLang: z.string().optional(),
37
+ translation: z.string().optional(),
38
+ translator: z.string().optional(),
39
+ license: z.string().optional(),
40
+ referenceImage: quoteImageRef.optional(),
41
+ flowImage: z
42
+ .object({
43
+ url: z.string(),
44
+ alt: z.string(),
45
+ caption: z.string().optional(),
46
+ prompt: z.string().optional(),
47
+ model: z.string().optional(),
48
+ })
49
+ .optional(),
50
+ });
51
+ const popReferencePayload = z.object({
52
+ featuredItemId: z.string(),
53
+ relatedItemIds: z.array(z.string()).optional(),
54
+ lensTheme: z.string().optional(),
55
+ });
56
+ const educativeReferencePayload = z.object({
57
+ sourceLessonId: z.string().describe("lessons:_id da aula fonte"),
58
+ sourceCourseSlug: z.string().optional(),
59
+ sourceModuleId: z.string().optional(),
60
+ angle: z.string().optional().describe("fundamento, case, objecao, exemplo…"),
61
+ partNumber: z.number().optional(),
62
+ totalParts: z.number().optional(),
63
+ });
64
+ export const quotePopSchema = z.object({
65
+ action: z.enum(["publish_quote", "publish_pop", "publish_educativo"]),
66
+ // Comuns
67
+ slug: z.string().describe("kebab-case único"),
68
+ title: z.string(),
69
+ excerpt: z.string().optional(),
70
+ content: z.string().describe("Markdown completo do artigo/comentário."),
71
+ tags: z
72
+ .array(z.string())
73
+ .optional()
74
+ .describe("Tags. Pra publish_quote, 'sapiens-column' é adicionada automaticamente se faltar."),
75
+ thumbnailUrl: z.string().optional(),
76
+ generatedBy: z
77
+ .string()
78
+ .optional()
79
+ .describe("Marker do gerador (ex: 'claude-opus-4.7-max:sapiens-quote-skill'). Default genérico se omitido."),
80
+ publishNow: z
81
+ .boolean()
82
+ .optional()
83
+ .describe("Default false (cria como draft). True publica direto sem passar por curation."),
84
+ // Quote-specific
85
+ tldr: z.string().optional(),
86
+ category: z.string().optional().describe("Default 'filosofia-tech' pra quote, 'repertorio' pra pop"),
87
+ format: z
88
+ .string()
89
+ .optional()
90
+ .describe("Pra publish_quote: 'short' (200-600 palavras) ou 'essay' (>600). Default 'short'."),
91
+ readingTimeMinutes: z.number().optional(),
92
+ connectedSlugs: z.array(z.string()).optional(),
93
+ quote: quotePayload
94
+ .optional()
95
+ .describe("Obrigatório pra publish_quote."),
96
+ overwrite: z
97
+ .boolean()
98
+ .optional()
99
+ .describe("Pra publish_quote: se draft com mesmo slug existe (status='draft', column='sapiens'), apaga e recria. Não sobrescreve published."),
100
+ // Pop-specific
101
+ popReference: popReferencePayload
102
+ .optional()
103
+ .describe("Obrigatório pra publish_pop. featuredItemId vem de sapiens_repertorio."),
104
+ // Educativo-specific
105
+ educativeReference: educativeReferencePayload
106
+ .optional()
107
+ .describe("Obrigatório pra publish_educativo. sourceLessonId vem da query lms:listLessonsForBlogPicker."),
108
+ });
109
+ export async function quotePop(args) {
110
+ const sessionToken = getSessionToken();
111
+ if (args.action === "publish_quote") {
112
+ if (!args.quote) {
113
+ throw new Error("publish_quote exige campo 'quote' (objeto).");
114
+ }
115
+ const tags = args.tags ?? [];
116
+ const result = await convexMutation("mcpExtras:mcpPublishQuote", {
117
+ sessionToken,
118
+ slug: args.slug,
119
+ title: args.title,
120
+ excerpt: args.excerpt,
121
+ tldr: args.tldr,
122
+ content: args.content,
123
+ category: args.category ?? "filosofia-tech",
124
+ tags,
125
+ format: args.format ?? "short",
126
+ readingTimeMinutes: args.readingTimeMinutes,
127
+ connectedSlugs: args.connectedSlugs,
128
+ quote: args.quote,
129
+ thumbnailUrl: args.thumbnailUrl,
130
+ generatedBy: args.generatedBy ?? "mcp-sapiens:quote",
131
+ overwrite: args.overwrite,
132
+ publishNow: args.publishNow,
133
+ });
134
+ return result;
135
+ }
136
+ if (args.action === "publish_pop") {
137
+ if (!args.popReference) {
138
+ throw new Error("publish_pop exige popReference { featuredItemId, relatedItemIds?, lensTheme? }.");
139
+ }
140
+ const result = await convexMutation("mcpExtras:mcpPublishPopArticle", {
141
+ sessionToken,
142
+ slug: args.slug,
143
+ title: args.title,
144
+ excerpt: args.excerpt,
145
+ content: args.content,
146
+ tags: args.tags,
147
+ thumbnailUrl: args.thumbnailUrl,
148
+ popReference: args.popReference,
149
+ generatedBy: args.generatedBy ?? "mcp-sapiens:pop",
150
+ publishNow: args.publishNow,
151
+ });
152
+ return result;
153
+ }
154
+ if (args.action === "publish_educativo") {
155
+ if (!args.educativeReference) {
156
+ throw new Error("publish_educativo exige educativeReference { sourceLessonId, angle?, partNumber?, totalParts? }.");
157
+ }
158
+ const result = await convexMutation("mcpExtras:mcpPublishEducativo", {
159
+ sessionToken,
160
+ slug: args.slug,
161
+ title: args.title,
162
+ excerpt: args.excerpt,
163
+ tldr: args.tldr,
164
+ content: args.content,
165
+ category: args.category,
166
+ tags: args.tags,
167
+ thumbnailUrl: args.thumbnailUrl,
168
+ readingTimeMinutes: args.readingTimeMinutes,
169
+ educativeReference: args.educativeReference,
170
+ generatedBy: args.generatedBy ?? "mcp-sapiens:educativo",
171
+ publishNow: args.publishNow,
172
+ });
173
+ return result;
174
+ }
175
+ }
@@ -0,0 +1,217 @@
1
+ import { z } from "zod";
2
+ import { convexMutation, convexQuery, getSessionToken, } from "../convexClient.js";
3
+ /**
4
+ * Acesso ao Repertório do Sapiens (acervo pessoal de filme/série/anime/jogo).
5
+ *
6
+ * Reads (v1.0): list, search, get, lists, popArticles. Sem auth necessária —
7
+ * só vê items públicos (isPublic !== false).
8
+ *
9
+ * Mutations (v1.1+): add_item, update_item, remove_item. Usam sessionToken
10
+ * e exigem admin (consistente com `requireMcpAdmin`). Ownership: tudo vai
11
+ * pro userId da sessão (o user logado). Caller NÃO escolhe pra quem
12
+ * adicionar.
13
+ *
14
+ * Action-based design (memoria: consolidar tools via args).
15
+ */
16
+ const mediaTypeEnum = z.enum(["movie", "series", "anime", "game"]);
17
+ const statusEnum = z.enum([
18
+ "backlog",
19
+ "active",
20
+ "completed",
21
+ "paused",
22
+ "dropped",
23
+ ]);
24
+ const sourceEnum = z.enum(["tmdb", "anilist", "rawg", "manual"]);
25
+ export const repertorioSchema = z.object({
26
+ action: z.enum([
27
+ "list",
28
+ "search",
29
+ "get",
30
+ "lists",
31
+ "popArticles",
32
+ "add_item",
33
+ "update_item",
34
+ "remove_item",
35
+ ]),
36
+ // Reads
37
+ userId: z
38
+ .string()
39
+ .optional()
40
+ .describe("users:_id (obrigatório pra list/search/lists). Descobre via sapiens_meta action=whoami."),
41
+ itemId: z
42
+ .string()
43
+ .optional()
44
+ .describe("repertorioItems:_id (obrigatório pra get/update_item/remove_item)."),
45
+ query: z.string().optional().describe("Texto pra action=search."),
46
+ mediaType: mediaTypeEnum.optional(),
47
+ status: statusEnum.optional(),
48
+ limit: z.number().int().positive().max(500).optional().default(100),
49
+ // add_item — todos os campos do schema repertorioItems user-side
50
+ source: sourceEnum
51
+ .optional()
52
+ .describe("Pra add_item: 'tmdb'/'anilist'/'rawg' se vem de provider externo; 'manual' pra entry hand-rolled (gere externalId único via UUID)."),
53
+ externalId: z
54
+ .string()
55
+ .optional()
56
+ .describe("Pra add_item: ID externo (tmdb/anilist/rawg) ou UUID se source='manual'. Dedup por (userId, source, externalId)."),
57
+ title: z.string().optional(),
58
+ titleOriginal: z.string().optional(),
59
+ year: z.number().optional(),
60
+ posterUrl: z.string().optional(),
61
+ backdropUrl: z.string().optional(),
62
+ overview: z.string().optional(),
63
+ genres: z.array(z.string()).optional(),
64
+ runtimeMin: z.number().optional(),
65
+ platforms: z.array(z.string()).optional(),
66
+ studios: z.array(z.string()).optional(),
67
+ rating: z
68
+ .number()
69
+ .nullable()
70
+ .optional()
71
+ .describe("0-10. Pra update_item: passe null pra remover rating."),
72
+ tags: z.array(z.string()).optional(),
73
+ note: z.string().optional().describe("Nota pessoal sobre a obra."),
74
+ containsSpoilers: z.boolean().optional(),
75
+ isPublic: z.boolean().optional(),
76
+ imdbId: z.string().optional(),
77
+ tmdbId: z.number().optional(),
78
+ traktId: z.number().optional(),
79
+ anilistId: z.number().optional(),
80
+ });
81
+ function need(value, name) {
82
+ if (value === undefined || value === null) {
83
+ throw new Error(`Faltando arg "${name}" pra essa action.`);
84
+ }
85
+ return value;
86
+ }
87
+ export async function repertorio(args) {
88
+ // popArticles: sem auth necessário (lê públicos)
89
+ if (args.action === "popArticles") {
90
+ const articles = await convexQuery("popArticles:listPublishedPopArticles", { limit: args.limit });
91
+ return {
92
+ count: Array.isArray(articles) ? articles.length : 0,
93
+ articles: (articles || []).map((a) => ({
94
+ slug: a.slug,
95
+ title: a.title,
96
+ excerpt: a.excerpt,
97
+ lensTheme: a.lensTheme,
98
+ publishedAt: a.publishedAt,
99
+ url: `/articles/${a.slug}`,
100
+ })),
101
+ };
102
+ }
103
+ // Mutations: sempre exigem sessionToken
104
+ if (args.action === "add_item" ||
105
+ args.action === "update_item" ||
106
+ args.action === "remove_item") {
107
+ const sessionToken = getSessionToken();
108
+ if (args.action === "add_item") {
109
+ return await convexMutation("mcpExtras:mcpAddRepertorioItem", {
110
+ sessionToken,
111
+ mediaType: need(args.mediaType, "mediaType"),
112
+ source: need(args.source, "source"),
113
+ externalId: need(args.externalId, "externalId"),
114
+ title: need(args.title, "title"),
115
+ titleOriginal: args.titleOriginal,
116
+ year: args.year,
117
+ posterUrl: args.posterUrl,
118
+ backdropUrl: args.backdropUrl,
119
+ overview: args.overview,
120
+ genres: args.genres,
121
+ runtimeMin: args.runtimeMin,
122
+ platforms: args.platforms,
123
+ studios: args.studios,
124
+ status: args.status,
125
+ rating: args.rating === null ? undefined : args.rating,
126
+ tags: args.tags,
127
+ note: args.note,
128
+ isPublic: args.isPublic,
129
+ imdbId: args.imdbId,
130
+ tmdbId: args.tmdbId,
131
+ traktId: args.traktId,
132
+ anilistId: args.anilistId,
133
+ });
134
+ }
135
+ if (args.action === "update_item") {
136
+ return await convexMutation("mcpExtras:mcpUpdateRepertorioItem", {
137
+ sessionToken,
138
+ itemId: need(args.itemId, "itemId"),
139
+ status: args.status,
140
+ rating: args.rating,
141
+ tags: args.tags,
142
+ note: args.note,
143
+ containsSpoilers: args.containsSpoilers,
144
+ isPublic: args.isPublic,
145
+ });
146
+ }
147
+ if (args.action === "remove_item") {
148
+ return await convexMutation("mcpExtras:mcpRemoveRepertorioItem", {
149
+ sessionToken,
150
+ itemId: need(args.itemId, "itemId"),
151
+ });
152
+ }
153
+ }
154
+ // Reads que exigem userId
155
+ if (!args.userId) {
156
+ throw new Error(`action=${args.action} exige userId. Use sapiens_meta action=whoami pra descobrir o user atual.`);
157
+ }
158
+ if (args.action === "lists") {
159
+ const lists = await convexQuery("repertorio:listLists", {
160
+ userId: args.userId,
161
+ });
162
+ return {
163
+ count: Array.isArray(lists) ? lists.length : 0,
164
+ lists: (lists || []).map((l) => ({
165
+ _id: l._id,
166
+ name: l.name,
167
+ slug: l.slug,
168
+ description: l.description,
169
+ isPublic: l.isPublic,
170
+ })),
171
+ };
172
+ }
173
+ if (args.action === "get") {
174
+ const item = await convexQuery("repertorio:getById", {
175
+ itemId: need(args.itemId, "itemId"),
176
+ });
177
+ if (!item)
178
+ return { error: "not found" };
179
+ return slimItem(item);
180
+ }
181
+ // list / search
182
+ const queryArgs = { userId: args.userId, limit: args.limit };
183
+ if (args.mediaType)
184
+ queryArgs.mediaType = args.mediaType;
185
+ if (args.status)
186
+ queryArgs.status = args.status;
187
+ const items = await convexQuery("repertorio:listByUser", queryArgs);
188
+ let filtered = items || [];
189
+ if (args.action === "search" && args.query?.trim()) {
190
+ const q = args.query.toLowerCase();
191
+ filtered = filtered.filter((it) => (it.title || "").toLowerCase().includes(q) ||
192
+ (it.titleOriginal || "").toLowerCase().includes(q) ||
193
+ (it.genres || []).some((g) => g.toLowerCase().includes(q)) ||
194
+ (it.tags || []).some((t) => t.toLowerCase().includes(q)));
195
+ }
196
+ return {
197
+ count: filtered.length,
198
+ items: filtered.map(slimItem),
199
+ };
200
+ }
201
+ function slimItem(it) {
202
+ return {
203
+ _id: it._id,
204
+ title: it.title,
205
+ titleOriginal: it.titleOriginal,
206
+ year: it.year,
207
+ mediaType: it.mediaType,
208
+ genres: it.genres || [],
209
+ tags: it.tags || [],
210
+ rating: it.rating,
211
+ note: it.note,
212
+ status: it.status,
213
+ posterUrl: it.posterUrl,
214
+ source: it.source,
215
+ externalId: it.externalId,
216
+ };
217
+ }