44reports-mcp 1.1.0 → 1.2.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.
- package/dist/api-client.d.ts +115 -4
- package/dist/api-client.js +77 -4
- package/dist/index.js +132 -10
- package/dist/tools/backlog.d.ts +58 -0
- package/dist/tools/backlog.js +46 -0
- package/dist/tools/backlog.test.d.ts +1 -0
- package/dist/tools/backlog.test.js +255 -0
- package/dist/tools/products.d.ts +117 -8
- package/dist/tools/products.js +75 -30
- package/package.json +1 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -56,6 +56,22 @@ export interface Folder {
|
|
|
56
56
|
created_at: string;
|
|
57
57
|
updated_at: string;
|
|
58
58
|
}
|
|
59
|
+
export interface BacklogEntry {
|
|
60
|
+
id: string;
|
|
61
|
+
product_slug: string | null;
|
|
62
|
+
content: string;
|
|
63
|
+
source: string | null;
|
|
64
|
+
source_context: string | null;
|
|
65
|
+
domain_hint: string | null;
|
|
66
|
+
tags: string | null;
|
|
67
|
+
status: string;
|
|
68
|
+
result_summary: string | null;
|
|
69
|
+
applied_changes: string | null;
|
|
70
|
+
rejection_reason: string | null;
|
|
71
|
+
processed_at: string | null;
|
|
72
|
+
created_at: string;
|
|
73
|
+
updated_at: string;
|
|
74
|
+
}
|
|
59
75
|
export declare class ApiClient {
|
|
60
76
|
private readonly config;
|
|
61
77
|
constructor(config: Config);
|
|
@@ -120,17 +136,22 @@ export declare class ApiClient {
|
|
|
120
136
|
removeReportFromFolder(folderSlug: string, reportSlug: string): Promise<{
|
|
121
137
|
success: boolean;
|
|
122
138
|
}>;
|
|
123
|
-
listProducts(
|
|
139
|
+
listProducts(options?: {
|
|
140
|
+
search?: string;
|
|
141
|
+
limit?: number;
|
|
142
|
+
offset?: number;
|
|
143
|
+
}): Promise<{
|
|
124
144
|
products: Array<{
|
|
125
145
|
id: string;
|
|
126
146
|
slug: string;
|
|
127
147
|
name: string;
|
|
128
148
|
description: string | null;
|
|
129
|
-
|
|
130
|
-
|
|
149
|
+
created_at: string;
|
|
150
|
+
updated_at: string;
|
|
131
151
|
}>;
|
|
152
|
+
total: number;
|
|
132
153
|
}>;
|
|
133
|
-
getProduct(slug: string): Promise<{
|
|
154
|
+
getProduct(slug: string, domain?: string): Promise<{
|
|
134
155
|
product: {
|
|
135
156
|
id: string;
|
|
136
157
|
slug: string;
|
|
@@ -140,10 +161,16 @@ export declare class ApiClient {
|
|
|
140
161
|
updated_at: string;
|
|
141
162
|
};
|
|
142
163
|
knowledge: Record<string, unknown>;
|
|
164
|
+
doc_files: Array<{
|
|
165
|
+
path: string;
|
|
166
|
+
size: number;
|
|
167
|
+
domain: string;
|
|
168
|
+
}>;
|
|
143
169
|
binary_files: Array<{
|
|
144
170
|
path: string;
|
|
145
171
|
size: number;
|
|
146
172
|
content_type: string;
|
|
173
|
+
domain: string;
|
|
147
174
|
}>;
|
|
148
175
|
}>;
|
|
149
176
|
getProductFile(slug: string, filepath: string): Promise<{
|
|
@@ -168,4 +195,88 @@ export declare class ApiClient {
|
|
|
168
195
|
success: boolean;
|
|
169
196
|
uploaded: string[];
|
|
170
197
|
}>;
|
|
198
|
+
getProductHealth(slug: string): Promise<{
|
|
199
|
+
slug: string;
|
|
200
|
+
name: string;
|
|
201
|
+
overall_score: number;
|
|
202
|
+
files: Record<string, unknown>;
|
|
203
|
+
recommendations: string[];
|
|
204
|
+
}>;
|
|
205
|
+
getAllProductsHealth(): Promise<{
|
|
206
|
+
products: Array<{
|
|
207
|
+
slug: string;
|
|
208
|
+
name: string;
|
|
209
|
+
overall_score: number;
|
|
210
|
+
files: Record<string, {
|
|
211
|
+
score: number;
|
|
212
|
+
missing_required: string[];
|
|
213
|
+
}>;
|
|
214
|
+
}>;
|
|
215
|
+
}>;
|
|
216
|
+
createProduct(data: {
|
|
217
|
+
name: string;
|
|
218
|
+
slug?: string;
|
|
219
|
+
description?: string;
|
|
220
|
+
}): Promise<{
|
|
221
|
+
product: {
|
|
222
|
+
id: string;
|
|
223
|
+
slug: string;
|
|
224
|
+
name: string;
|
|
225
|
+
description: string | null;
|
|
226
|
+
created_at: string;
|
|
227
|
+
updated_at: string;
|
|
228
|
+
};
|
|
229
|
+
}>;
|
|
230
|
+
deleteProduct(slug: string): Promise<{
|
|
231
|
+
success: boolean;
|
|
232
|
+
}>;
|
|
233
|
+
deleteProductFile(slug: string, filepath: string): Promise<{
|
|
234
|
+
success: boolean;
|
|
235
|
+
}>;
|
|
236
|
+
getProductIndex(slug: string, domain?: string): Promise<{
|
|
237
|
+
product_slug: string;
|
|
238
|
+
updated_at: string;
|
|
239
|
+
files: Array<{
|
|
240
|
+
path: string;
|
|
241
|
+
title: string;
|
|
242
|
+
summary: string;
|
|
243
|
+
sections: string[];
|
|
244
|
+
domain: string;
|
|
245
|
+
content_type: string;
|
|
246
|
+
size: number;
|
|
247
|
+
updated_at: string;
|
|
248
|
+
}>;
|
|
249
|
+
}>;
|
|
250
|
+
pullProductFiles(slug: string, options?: {
|
|
251
|
+
files?: string[];
|
|
252
|
+
include_knowledge?: boolean;
|
|
253
|
+
}): Promise<{
|
|
254
|
+
files: PulledFile[];
|
|
255
|
+
}>;
|
|
256
|
+
addBacklogEntry(data: {
|
|
257
|
+
content: string;
|
|
258
|
+
product_slug?: string;
|
|
259
|
+
source?: string;
|
|
260
|
+
source_context?: string;
|
|
261
|
+
domain_hint?: string;
|
|
262
|
+
tags?: string[];
|
|
263
|
+
}): Promise<{
|
|
264
|
+
entry: BacklogEntry;
|
|
265
|
+
}>;
|
|
266
|
+
listBacklog(options?: {
|
|
267
|
+
product_slug?: string;
|
|
268
|
+
status?: string;
|
|
269
|
+
limit?: number;
|
|
270
|
+
offset?: number;
|
|
271
|
+
}): Promise<{
|
|
272
|
+
entries: BacklogEntry[];
|
|
273
|
+
total: number;
|
|
274
|
+
}>;
|
|
275
|
+
deleteBacklogEntry(id: string): Promise<{
|
|
276
|
+
success: boolean;
|
|
277
|
+
}>;
|
|
278
|
+
processBacklog(options?: {
|
|
279
|
+
product_slug?: string;
|
|
280
|
+
dry_run?: boolean;
|
|
281
|
+
}): Promise<unknown>;
|
|
171
282
|
}
|
package/dist/api-client.js
CHANGED
|
@@ -91,11 +91,19 @@ export class ApiClient {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
// --- Product Knowledge ---
|
|
94
|
-
async listProducts() {
|
|
95
|
-
|
|
94
|
+
async listProducts(options) {
|
|
95
|
+
const params = new URLSearchParams();
|
|
96
|
+
for (const [key, value] of Object.entries(options ?? {})) {
|
|
97
|
+
if (value !== undefined) {
|
|
98
|
+
params.set(key, String(value));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const query = params.toString();
|
|
102
|
+
return this.request(`/api/products${query ? `?${query}` : ''}`);
|
|
96
103
|
}
|
|
97
|
-
async getProduct(slug) {
|
|
98
|
-
|
|
104
|
+
async getProduct(slug, domain) {
|
|
105
|
+
const query = domain ? `?domain=${encodeURIComponent(domain)}` : '';
|
|
106
|
+
return this.request(`/api/products/${slug}${query}`);
|
|
99
107
|
}
|
|
100
108
|
async getProductFile(slug, filepath) {
|
|
101
109
|
return this.request(`/api/products/${slug}/files/${filepath}`);
|
|
@@ -112,4 +120,69 @@ export class ApiClient {
|
|
|
112
120
|
body: JSON.stringify({ files }),
|
|
113
121
|
});
|
|
114
122
|
}
|
|
123
|
+
async getProductHealth(slug) {
|
|
124
|
+
return this.request(`/api/products/${slug}/health`);
|
|
125
|
+
}
|
|
126
|
+
async getAllProductsHealth() {
|
|
127
|
+
return this.request('/api/products/health');
|
|
128
|
+
}
|
|
129
|
+
async createProduct(data) {
|
|
130
|
+
return this.request('/api/products', {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
body: JSON.stringify(data),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async deleteProduct(slug) {
|
|
136
|
+
return this.request(`/api/products/${slug}`, {
|
|
137
|
+
method: 'DELETE',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async deleteProductFile(slug, filepath) {
|
|
141
|
+
return this.request(`/api/products/${slug}/files/${filepath}`, {
|
|
142
|
+
method: 'DELETE',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async getProductIndex(slug, domain) {
|
|
146
|
+
const query = domain ? `?domain=${encodeURIComponent(domain)}` : '';
|
|
147
|
+
return this.request(`/api/products/${slug}/index${query}`);
|
|
148
|
+
}
|
|
149
|
+
async pullProductFiles(slug, options) {
|
|
150
|
+
return this.request(`/api/products/${slug}/pull`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
body: JSON.stringify(options ?? {}),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// --- Agent Backlog ---
|
|
156
|
+
async addBacklogEntry(data) {
|
|
157
|
+
return this.request('/api/backlog', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
body: JSON.stringify(data),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async listBacklog(options) {
|
|
163
|
+
const params = new URLSearchParams();
|
|
164
|
+
for (const [key, value] of Object.entries(options ?? {})) {
|
|
165
|
+
if (value !== undefined) {
|
|
166
|
+
params.set(key, String(value));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const query = params.toString();
|
|
170
|
+
return this.request(`/api/backlog${query ? `?${query}` : ''}`);
|
|
171
|
+
}
|
|
172
|
+
async deleteBacklogEntry(id) {
|
|
173
|
+
return this.request(`/api/backlog/${id}`, {
|
|
174
|
+
method: 'DELETE',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async processBacklog(options) {
|
|
178
|
+
const params = new URLSearchParams();
|
|
179
|
+
if (options?.product_slug)
|
|
180
|
+
params.set('product_slug', options.product_slug);
|
|
181
|
+
if (options?.dry_run !== undefined)
|
|
182
|
+
params.set('dry_run', String(options.dry_run));
|
|
183
|
+
const query = params.toString();
|
|
184
|
+
return this.request(`/api/backlog/process${query ? `?${query}` : ''}`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
115
188
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,8 @@ import { deployReportSchema, updateReportSchema, createDeployTool, createUpdateT
|
|
|
8
8
|
import { listReportsSchema, getReportSchema, deleteReportSchema, createListTool, createGetTool, createDeleteTool, } from './tools/reports.js';
|
|
9
9
|
import { pullContextSchema, createPullTool, } from './tools/pull.js';
|
|
10
10
|
import { manageFoldersSchema, createManageFoldersTool, } from './tools/folders.js';
|
|
11
|
-
import { listProductsSchema, getProductSchema, readProductFileSchema, updateProductKnowledgeSchema, uploadProductFilesSchema, pullProductFilesSchema, createListProductsTool, createGetProductTool, createReadProductFileTool, createUpdateProductKnowledgeTool, createUploadProductFilesTool, createPullProductFilesTool, } from './tools/products.js';
|
|
11
|
+
import { listProductsSchema, getProductSchema, readProductFileSchema, updateProductKnowledgeSchema, uploadProductFilesSchema, pullProductFilesSchema, createProductSchema, deleteProductSchema, deleteProductFileSchema, getProductHealthSchema, getProductIndexSchema, createListProductsTool, createGetProductTool, createReadProductFileTool, createUpdateProductKnowledgeTool, createUploadProductFilesTool, createPullProductFilesTool, createGetProductHealthTool, createGetProductIndexTool, createCreateProductTool, createDeleteProductTool, createDeleteProductFileTool, } from './tools/products.js';
|
|
12
|
+
import { addToBacklogSchema, listBacklogSchema, processBacklogSchema, createAddToBacklogTool, createListBacklogTool, createProcessBacklogTool, } from './tools/backlog.js';
|
|
12
13
|
const config = loadConfig();
|
|
13
14
|
const client = new ApiClient(config);
|
|
14
15
|
function prop(type, description, extra = {}) {
|
|
@@ -139,18 +140,24 @@ const toolDefinitions = [
|
|
|
139
140
|
},
|
|
140
141
|
{
|
|
141
142
|
name: 'list_products',
|
|
142
|
-
description: 'List all
|
|
143
|
-
inputSchema: {
|
|
143
|
+
description: 'List all products in the knowledge repository with their knowledge file inventory. Call this first to discover available products and which knowledge files are already populated.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
search: prop('string', 'Search term to filter products by name or description'),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
144
150
|
schema: listProductsSchema,
|
|
145
151
|
handler: createListProductsTool(client),
|
|
146
152
|
},
|
|
147
153
|
{
|
|
148
154
|
name: 'get_product',
|
|
149
|
-
description: 'Get a product\'s complete knowledge — returns
|
|
155
|
+
description: 'Get a product\'s complete knowledge — returns all JSON config files (meta-ads.json, cpps.json, brand-config.json) fully parsed, plus listings of markdown docs and binary assets. This is the primary tool to read before generating creative work, writing ad copy, or configuring campaigns. For narrative knowledge (brand voice, product positioning, research), use get_product_index to find the right markdown doc, then read_product_file to get its content.\n\nOptionally filter by domain to get only files relevant to a specific area: product-research, brand, copy, advertising, or app-store. Note: JSON config files are domain-scoped (meta-ads.json → advertising, brand-config.json → brand, cpps.json → app-store), so domains without a config file will return an empty knowledge object.',
|
|
150
156
|
inputSchema: {
|
|
151
157
|
type: 'object',
|
|
152
158
|
properties: {
|
|
153
159
|
slug: prop('string', 'Product slug'),
|
|
160
|
+
domain: prop('string', 'Filter files by domain', { enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'] }),
|
|
154
161
|
},
|
|
155
162
|
required: ['slug'],
|
|
156
163
|
},
|
|
@@ -159,12 +166,12 @@ const toolDefinitions = [
|
|
|
159
166
|
},
|
|
160
167
|
{
|
|
161
168
|
name: 'read_product_file',
|
|
162
|
-
description: 'Read a single file from a product by path. Use get_product to read all knowledge files at once (more efficient). Use read_product_file only when you need one specific file —
|
|
169
|
+
description: 'Read a single file from a product by path. Use get_product to read all knowledge files at once (more efficient). Use read_product_file only when you need one specific file — works for markdown docs, JSON configs, and binary files (logos, PDFs returned as base64 data URIs).\n\nTypical workflow: get_product_index → find the file you need → read_product_file.',
|
|
163
170
|
inputSchema: {
|
|
164
171
|
type: 'object',
|
|
165
172
|
properties: {
|
|
166
173
|
slug: prop('string', 'Product slug'),
|
|
167
|
-
filepath: prop('string', 'File path within the product (e.g., "meta-ads.json", "brand-assets/logo.png")'),
|
|
174
|
+
filepath: prop('string', 'File path within the product (e.g., "docs/research/user-research.md", "meta-ads.json", "brand-assets/logo.png")'),
|
|
168
175
|
},
|
|
169
176
|
required: ['slug', 'filepath'],
|
|
170
177
|
},
|
|
@@ -173,12 +180,12 @@ const toolDefinitions = [
|
|
|
173
180
|
},
|
|
174
181
|
{
|
|
175
182
|
name: 'update_product_knowledge',
|
|
176
|
-
description: 'Write or update product knowledge using deep merge-patch — only the keys you send are changed, all other data is preserved.
|
|
183
|
+
description: 'Write or update product knowledge JSON config using deep merge-patch — only the keys you send are changed, all other data is preserved. Use this for programmatic config files that agents plug into API calls.\n\nConfig files per product:\n- meta-ads.json — Meta account IDs, pixels, creative test config\n- cpps.json — CPP motivations and custom product page config\n- brand-config.json — brand color palette hex values, font families\n\nFor narrative knowledge (product info, brand voice, copy, research), create or update Markdown docs using upload_product_files instead. Place docs in the correct domain prefix:\n- docs/research/ — user research, competitive analysis, hypotheses, GTM strategy, roadmap\n- docs/brand/ — brand voice, positioning\n- docs/copy/ — copy guidelines, messaging\n- docs/advertising/ — campaign briefs, ad copy\n- docs/app-store/ — store listing copy, metadata\n\nAlways pass updated_by with your agent name so humans can see who wrote each update.',
|
|
177
184
|
inputSchema: {
|
|
178
185
|
type: 'object',
|
|
179
186
|
properties: {
|
|
180
187
|
slug: prop('string', 'Product slug'),
|
|
181
|
-
filepath: prop('string', '
|
|
188
|
+
filepath: prop('string', 'Config file to update (e.g., "meta-ads.json", "cpps.json", "brand-config.json")'),
|
|
182
189
|
content: prop('object', 'JSON object to deep merge-patch into the knowledge file. Use null values to delete keys.'),
|
|
183
190
|
updated_by: prop('string', 'Agent name or ID writing the update (optional)'),
|
|
184
191
|
},
|
|
@@ -189,7 +196,7 @@ const toolDefinitions = [
|
|
|
189
196
|
},
|
|
190
197
|
{
|
|
191
198
|
name: 'upload_product_files',
|
|
192
|
-
description: 'Upload
|
|
199
|
+
description: 'Upload files to a product — binary assets or Markdown knowledge docs. Files are organized into sections:\n\nBinary assets:\n- brand-assets/ — logos, brand book PDFs, color swatches\n- store-listings/ — App Store / Play Store screenshots and preview videos\n- assets/ — ad creatives, icons, campaign videos\n\nMarkdown docs (use these prefixes for knowledge documents):\n- docs/research/ — user research, competitive analysis, hypotheses, GTM strategy, roadmap\n- docs/brand/ — brand voice, positioning\n- docs/copy/ — copy guidelines, messaging\n- docs/advertising/ — campaign briefs, ad copy\n- docs/app-store/ — store listing copy, metadata',
|
|
193
200
|
inputSchema: {
|
|
194
201
|
type: 'object',
|
|
195
202
|
properties: {
|
|
@@ -213,19 +220,134 @@ const toolDefinitions = [
|
|
|
213
220
|
},
|
|
214
221
|
{
|
|
215
222
|
name: 'pull_product_files',
|
|
216
|
-
description: 'Download
|
|
223
|
+
description: 'Download files from a product to a local directory. By default pulls binary assets (logos, screenshots, videos). Set include_knowledge to also pull JSON config files. To pull markdown docs, specify their paths in the files array (e.g., ["docs/research/user-research.md"]).',
|
|
217
224
|
inputSchema: {
|
|
218
225
|
type: 'object',
|
|
219
226
|
properties: {
|
|
220
227
|
slug: prop('string', 'Product slug'),
|
|
221
228
|
output_dir: prop('string', 'Local directory to write files to. Will be created if needed.'),
|
|
222
229
|
files: prop('array', 'Specific file paths to pull. If omitted, all binary files are pulled.', { items: { type: 'string' } }),
|
|
230
|
+
include_knowledge: prop('boolean', 'Include knowledge JSON files in addition to binary files. Default: false.'),
|
|
223
231
|
},
|
|
224
232
|
required: ['slug', 'output_dir'],
|
|
225
233
|
},
|
|
226
234
|
schema: pullProductFilesSchema,
|
|
227
235
|
handler: createPullProductFilesTool(client),
|
|
228
236
|
},
|
|
237
|
+
{
|
|
238
|
+
name: 'get_product_health',
|
|
239
|
+
description: 'Get knowledge completeness scores for a product or all products. Scores both JSON config files (field-level completeness) and expected markdown docs (presence check). Shows missing required/optional fields and missing docs, with an overall health score (0-100). Use to identify gaps before generating content.',
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
slug: prop('string', 'Product slug. If omitted, returns health summary for all products.'),
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
schema: getProductHealthSchema,
|
|
247
|
+
handler: createGetProductHealthTool(client),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: 'get_product_index',
|
|
251
|
+
description: 'Get a lightweight file index for a product — a table of contents listing every file with its title, summary, sections, domain, and size. Use this FIRST to discover what knowledge is available before pulling specific files. Much faster than get_product since it returns metadata only, not file contents.\n\nTypical workflow: list_products → get_product_index → read_product_file (for specific docs) or get_product (for all JSON configs at once).\n\nOptionally filter by domain to see only files in a specific area: product-research, brand, copy, advertising, or app-store.',
|
|
252
|
+
inputSchema: {
|
|
253
|
+
type: 'object',
|
|
254
|
+
properties: {
|
|
255
|
+
slug: prop('string', 'Product slug'),
|
|
256
|
+
domain: prop('string', 'Filter index entries by domain', { enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'] }),
|
|
257
|
+
},
|
|
258
|
+
required: ['slug'],
|
|
259
|
+
},
|
|
260
|
+
schema: getProductIndexSchema,
|
|
261
|
+
handler: createGetProductIndexTool(client),
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'create_product',
|
|
265
|
+
description: 'Create a new product in the knowledge repository. The slug is auto-generated from the name if not provided. Use this before uploading knowledge files or assets.',
|
|
266
|
+
inputSchema: {
|
|
267
|
+
type: 'object',
|
|
268
|
+
properties: {
|
|
269
|
+
name: prop('string', 'Product name'),
|
|
270
|
+
slug: prop('string', 'URL-friendly slug (auto-generated from name if not provided)'),
|
|
271
|
+
description: prop('string', 'Product description'),
|
|
272
|
+
},
|
|
273
|
+
required: ['name'],
|
|
274
|
+
},
|
|
275
|
+
schema: createProductSchema,
|
|
276
|
+
handler: createCreateProductTool(client),
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'delete_product',
|
|
280
|
+
description: 'Delete a product and all its associated knowledge files and assets from the repository. This action is irreversible.',
|
|
281
|
+
inputSchema: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
properties: {
|
|
284
|
+
slug: prop('string', 'Product slug to delete'),
|
|
285
|
+
},
|
|
286
|
+
required: ['slug'],
|
|
287
|
+
},
|
|
288
|
+
schema: deleteProductSchema,
|
|
289
|
+
handler: createDeleteProductTool(client),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'delete_product_file',
|
|
293
|
+
description: 'Delete a single file from a product. Use for removing outdated assets or resetting a knowledge file.',
|
|
294
|
+
inputSchema: {
|
|
295
|
+
type: 'object',
|
|
296
|
+
properties: {
|
|
297
|
+
slug: prop('string', 'Product slug'),
|
|
298
|
+
filepath: prop('string', 'File path within the product to delete (e.g., "brand-assets/old-logo.png")'),
|
|
299
|
+
},
|
|
300
|
+
required: ['slug', 'filepath'],
|
|
301
|
+
},
|
|
302
|
+
schema: deleteProductFileSchema,
|
|
303
|
+
handler: createDeleteProductFileTool(client),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'add_to_backlog',
|
|
307
|
+
description: 'Add an unstructured observation to the agent backlog for later processing. Low-friction way for any agent to contribute signals — competitor insights, user feedback, research findings, or any data that should eventually enrich product knowledge. Only content is required; everything else is optional.\n\nIf you know which product this relates to, pass product_slug. Otherwise, omit it — the backlog processor will auto-route it to the right product (or reject it as noise).',
|
|
308
|
+
inputSchema: {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: {
|
|
311
|
+
content: prop('string', 'The observation or data (100-2000 chars typical). Be specific — include sources, numbers, quotes when available.'),
|
|
312
|
+
product_slug: prop('string', 'Product slug if known. Omit for global entries that will be auto-routed.'),
|
|
313
|
+
source: prop('string', 'Your agent name or ID (e.g., "research-agent", "competitor-monitor")'),
|
|
314
|
+
source_context: prop('string', 'Context slug this observation came from, if applicable'),
|
|
315
|
+
domain_hint: prop('string', 'Knowledge domain hint', { enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'] }),
|
|
316
|
+
tags: prop('array', 'Free-form tags for categorization', { items: { type: 'string' } }),
|
|
317
|
+
},
|
|
318
|
+
required: ['content'],
|
|
319
|
+
},
|
|
320
|
+
schema: addToBacklogSchema,
|
|
321
|
+
handler: createAddToBacklogTool(client),
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: 'list_backlog',
|
|
325
|
+
description: 'List backlog entries — inspect the queue of pending observations waiting to be processed into product knowledge. Filter by product, status, or both.',
|
|
326
|
+
inputSchema: {
|
|
327
|
+
type: 'object',
|
|
328
|
+
properties: {
|
|
329
|
+
product_slug: prop('string', 'Filter by product slug. Use "global" for unrouted entries without a product.'),
|
|
330
|
+
status: prop('string', 'Filter by status', { enum: ['pending', 'processing', 'applied', 'rejected', 'partial'] }),
|
|
331
|
+
limit: prop('number', 'Maximum number of results (default: 50)'),
|
|
332
|
+
offset: prop('number', 'Offset for pagination'),
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
schema: listBacklogSchema,
|
|
336
|
+
handler: createListBacklogTool(client),
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: 'process_backlog',
|
|
340
|
+
description: 'Trigger backlog processing — routes global entries to products, then uses LLM to triage and apply changes to product knowledge (JSON configs and markdown docs). Defaults to dry_run=true for safety.\n\nProcessing stages:\n1. Route: Global entries (no product_slug) are matched to products or rejected\n2. Process: Per-product entries are classified and generate JSON patches or markdown operations\n3. Apply: Only high-confidence changes are auto-applied; medium/low are marked as partial with suggestions',
|
|
341
|
+
inputSchema: {
|
|
342
|
+
type: 'object',
|
|
343
|
+
properties: {
|
|
344
|
+
product_slug: prop('string', 'Process entries for a specific product only. Omit to process all products including global routing.'),
|
|
345
|
+
dry_run: prop('boolean', 'Preview changes without applying them. Default: true.'),
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
schema: processBacklogSchema,
|
|
349
|
+
handler: createProcessBacklogTool(client),
|
|
350
|
+
},
|
|
229
351
|
];
|
|
230
352
|
const toolHandlers = Object.fromEntries(toolDefinitions.map((t) => [t.name, { schema: t.schema, handler: t.handler }]));
|
|
231
353
|
const server = new Server({ name: '44reports', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ApiClient } from '../api-client.js';
|
|
3
|
+
export declare const addToBacklogSchema: z.ZodObject<{
|
|
4
|
+
content: z.ZodString;
|
|
5
|
+
product_slug: z.ZodOptional<z.ZodString>;
|
|
6
|
+
source: z.ZodOptional<z.ZodString>;
|
|
7
|
+
source_context: z.ZodOptional<z.ZodString>;
|
|
8
|
+
domain_hint: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
|
|
9
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
10
|
+
}, "strip", z.ZodTypeAny, {
|
|
11
|
+
content: string;
|
|
12
|
+
product_slug?: string | undefined;
|
|
13
|
+
tags?: string[] | undefined;
|
|
14
|
+
source?: string | undefined;
|
|
15
|
+
source_context?: string | undefined;
|
|
16
|
+
domain_hint?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
17
|
+
}, {
|
|
18
|
+
content: string;
|
|
19
|
+
product_slug?: string | undefined;
|
|
20
|
+
tags?: string[] | undefined;
|
|
21
|
+
source?: string | undefined;
|
|
22
|
+
source_context?: string | undefined;
|
|
23
|
+
domain_hint?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
24
|
+
}>;
|
|
25
|
+
export declare const listBacklogSchema: z.ZodObject<{
|
|
26
|
+
product_slug: z.ZodOptional<z.ZodString>;
|
|
27
|
+
status: z.ZodOptional<z.ZodEnum<["pending", "processing", "applied", "rejected", "partial"]>>;
|
|
28
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
29
|
+
offset: z.ZodOptional<z.ZodNumber>;
|
|
30
|
+
}, "strip", z.ZodTypeAny, {
|
|
31
|
+
status?: "pending" | "processing" | "applied" | "rejected" | "partial" | undefined;
|
|
32
|
+
limit?: number | undefined;
|
|
33
|
+
offset?: number | undefined;
|
|
34
|
+
product_slug?: string | undefined;
|
|
35
|
+
}, {
|
|
36
|
+
status?: "pending" | "processing" | "applied" | "rejected" | "partial" | undefined;
|
|
37
|
+
limit?: number | undefined;
|
|
38
|
+
offset?: number | undefined;
|
|
39
|
+
product_slug?: string | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
export declare const processBacklogSchema: z.ZodObject<{
|
|
42
|
+
product_slug: z.ZodOptional<z.ZodString>;
|
|
43
|
+
dry_run: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
44
|
+
}, "strip", z.ZodTypeAny, {
|
|
45
|
+
dry_run: boolean;
|
|
46
|
+
product_slug?: string | undefined;
|
|
47
|
+
}, {
|
|
48
|
+
product_slug?: string | undefined;
|
|
49
|
+
dry_run?: boolean | undefined;
|
|
50
|
+
}>;
|
|
51
|
+
export declare function createAddToBacklogTool(client: ApiClient): (input: z.infer<typeof addToBacklogSchema>) => Promise<{
|
|
52
|
+
entry: import("../api-client.js").BacklogEntry;
|
|
53
|
+
}>;
|
|
54
|
+
export declare function createListBacklogTool(client: ApiClient): (input: z.infer<typeof listBacklogSchema>) => Promise<{
|
|
55
|
+
entries: import("../api-client.js").BacklogEntry[];
|
|
56
|
+
total: number;
|
|
57
|
+
}>;
|
|
58
|
+
export declare function createProcessBacklogTool(client: ApiClient): (input: z.infer<typeof processBacklogSchema>) => Promise<unknown>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
|
|
3
|
+
const STATUS_VALUES = ['pending', 'processing', 'applied', 'rejected', 'partial'];
|
|
4
|
+
// --- Schemas ---
|
|
5
|
+
export const addToBacklogSchema = z.object({
|
|
6
|
+
content: z.string().describe('The observation or data to add to the backlog'),
|
|
7
|
+
product_slug: z.string().optional().describe('Product slug — omit for global entries that will be auto-routed'),
|
|
8
|
+
source: z.string().optional().describe('Agent name or ID submitting this entry'),
|
|
9
|
+
source_context: z.string().optional().describe('Context slug this observation came from'),
|
|
10
|
+
domain_hint: z.enum(DOMAIN_VALUES).optional().describe('Knowledge domain hint'),
|
|
11
|
+
tags: z.array(z.string()).optional().describe('Free-form tags for categorization'),
|
|
12
|
+
});
|
|
13
|
+
export const listBacklogSchema = z.object({
|
|
14
|
+
product_slug: z.string().optional().describe('Filter by product slug. Use "global" for unrouted entries.'),
|
|
15
|
+
status: z.enum(STATUS_VALUES).optional().describe('Filter by status'),
|
|
16
|
+
limit: z.number().optional().describe('Maximum number of results (default: 50)'),
|
|
17
|
+
offset: z.number().optional().describe('Offset for pagination'),
|
|
18
|
+
});
|
|
19
|
+
export const processBacklogSchema = z.object({
|
|
20
|
+
product_slug: z.string().optional().describe('Process entries for a specific product. Omit to process all including global routing.'),
|
|
21
|
+
dry_run: z.boolean().optional().default(true).describe('Preview changes without applying. Default: true.'),
|
|
22
|
+
});
|
|
23
|
+
// --- Tool factories ---
|
|
24
|
+
export function createAddToBacklogTool(client) {
|
|
25
|
+
return async (input) => {
|
|
26
|
+
return client.addBacklogEntry(input);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function createListBacklogTool(client) {
|
|
30
|
+
return async (input) => {
|
|
31
|
+
return client.listBacklog({
|
|
32
|
+
product_slug: input.product_slug,
|
|
33
|
+
status: input.status,
|
|
34
|
+
limit: input.limit,
|
|
35
|
+
offset: input.offset,
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function createProcessBacklogTool(client) {
|
|
40
|
+
return async (input) => {
|
|
41
|
+
return client.processBacklog({
|
|
42
|
+
product_slug: input.product_slug,
|
|
43
|
+
dry_run: input.dry_run,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { addToBacklogSchema, listBacklogSchema, processBacklogSchema, createAddToBacklogTool, createListBacklogTool, createProcessBacklogTool, } from './backlog.js';
|
|
2
|
+
// --- Schema validation tests ---
|
|
3
|
+
describe('addToBacklogSchema', () => {
|
|
4
|
+
it('requires content', () => {
|
|
5
|
+
const result = addToBacklogSchema.safeParse({});
|
|
6
|
+
expect(result.success).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
it('accepts content only', () => {
|
|
9
|
+
const result = addToBacklogSchema.safeParse({ content: 'User mentioned they love the dark mode feature' });
|
|
10
|
+
expect(result.success).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
it('accepts all optional fields', () => {
|
|
13
|
+
const result = addToBacklogSchema.safeParse({
|
|
14
|
+
content: 'Competitor launched new pricing tier at $9.99/mo',
|
|
15
|
+
product_slug: 'my-app',
|
|
16
|
+
source: 'competitor-monitor',
|
|
17
|
+
source_context: 'weekly-research',
|
|
18
|
+
domain_hint: 'product-research',
|
|
19
|
+
tags: ['competitor', 'pricing'],
|
|
20
|
+
});
|
|
21
|
+
expect(result.success).toBe(true);
|
|
22
|
+
if (result.success) {
|
|
23
|
+
expect(result.data.product_slug).toBe('my-app');
|
|
24
|
+
expect(result.data.tags).toEqual(['competitor', 'pricing']);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('rejects invalid domain_hint', () => {
|
|
28
|
+
const result = addToBacklogSchema.safeParse({
|
|
29
|
+
content: 'Some observation',
|
|
30
|
+
domain_hint: 'invalid-domain',
|
|
31
|
+
});
|
|
32
|
+
expect(result.success).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('listBacklogSchema', () => {
|
|
36
|
+
it('accepts empty input', () => {
|
|
37
|
+
const result = listBacklogSchema.safeParse({});
|
|
38
|
+
expect(result.success).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('accepts all filters', () => {
|
|
41
|
+
const result = listBacklogSchema.safeParse({
|
|
42
|
+
product_slug: 'my-app',
|
|
43
|
+
status: 'pending',
|
|
44
|
+
limit: 10,
|
|
45
|
+
offset: 20,
|
|
46
|
+
});
|
|
47
|
+
expect(result.success).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('rejects invalid status', () => {
|
|
50
|
+
const result = listBacklogSchema.safeParse({ status: 'invalid' });
|
|
51
|
+
expect(result.success).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('processBacklogSchema', () => {
|
|
55
|
+
it('accepts empty input with dry_run defaulting to true', () => {
|
|
56
|
+
const result = processBacklogSchema.safeParse({});
|
|
57
|
+
expect(result.success).toBe(true);
|
|
58
|
+
if (result.success) {
|
|
59
|
+
expect(result.data.dry_run).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
it('accepts product_slug and dry_run false', () => {
|
|
63
|
+
const result = processBacklogSchema.safeParse({
|
|
64
|
+
product_slug: 'my-app',
|
|
65
|
+
dry_run: false,
|
|
66
|
+
});
|
|
67
|
+
expect(result.success).toBe(true);
|
|
68
|
+
if (result.success) {
|
|
69
|
+
expect(result.data.dry_run).toBe(false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
function makeEntry(overrides = {}) {
|
|
74
|
+
return {
|
|
75
|
+
id: 'entry-1',
|
|
76
|
+
product_slug: null,
|
|
77
|
+
content: 'Test observation',
|
|
78
|
+
source: null,
|
|
79
|
+
source_context: null,
|
|
80
|
+
domain_hint: null,
|
|
81
|
+
tags: null,
|
|
82
|
+
status: 'pending',
|
|
83
|
+
result_summary: null,
|
|
84
|
+
applied_changes: null,
|
|
85
|
+
rejection_reason: null,
|
|
86
|
+
processed_at: null,
|
|
87
|
+
created_at: '2026-03-06T00:00:00Z',
|
|
88
|
+
updated_at: '2026-03-06T00:00:00Z',
|
|
89
|
+
...overrides,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Simple call tracker — records arguments passed to each mock method */
|
|
93
|
+
function createTrackedClient(mocks) {
|
|
94
|
+
const calls = {
|
|
95
|
+
addBacklogEntry: [],
|
|
96
|
+
listBacklog: [],
|
|
97
|
+
processBacklog: [],
|
|
98
|
+
};
|
|
99
|
+
const client = {
|
|
100
|
+
addBacklogEntry: async (data) => {
|
|
101
|
+
calls.addBacklogEntry.push(data);
|
|
102
|
+
return mocks.addBacklogEntry?.(data) ?? {};
|
|
103
|
+
},
|
|
104
|
+
listBacklog: async (options) => {
|
|
105
|
+
calls.listBacklog.push(options);
|
|
106
|
+
return mocks.listBacklog?.(options) ?? { entries: [], total: 0 };
|
|
107
|
+
},
|
|
108
|
+
processBacklog: async (options) => {
|
|
109
|
+
calls.processBacklog.push(options);
|
|
110
|
+
return mocks.processBacklog?.(options) ?? {};
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return { client, calls };
|
|
114
|
+
}
|
|
115
|
+
describe('add_to_backlog → list_backlog → process_backlog flow', () => {
|
|
116
|
+
it('adds an entry, lists it, then processes it', async () => {
|
|
117
|
+
const entry = makeEntry({
|
|
118
|
+
content: 'Users report competitor app Calm added sleep stories',
|
|
119
|
+
product_slug: 'my-meditation-app',
|
|
120
|
+
source: 'research-agent',
|
|
121
|
+
domain_hint: 'product-research',
|
|
122
|
+
tags: '["competitor","feature"]',
|
|
123
|
+
});
|
|
124
|
+
const processResponse = {
|
|
125
|
+
routed: 0,
|
|
126
|
+
processed: 1,
|
|
127
|
+
applied: 1,
|
|
128
|
+
rejected: 0,
|
|
129
|
+
partial: 0,
|
|
130
|
+
errors: [],
|
|
131
|
+
details: [{
|
|
132
|
+
id: entry.id,
|
|
133
|
+
status: 'applied',
|
|
134
|
+
result_summary: 'Added competitor insight to research doc',
|
|
135
|
+
applied_changes: [{ filepath: 'docs/research/competitors.md', action: 'md_append', product_slug: 'my-meditation-app' }],
|
|
136
|
+
rejection_reason: null,
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
const { client, calls } = createTrackedClient({
|
|
140
|
+
addBacklogEntry: async () => ({ entry }),
|
|
141
|
+
listBacklog: async () => ({ entries: [entry], total: 1 }),
|
|
142
|
+
processBacklog: async () => processResponse,
|
|
143
|
+
});
|
|
144
|
+
// Step 1: Add entry
|
|
145
|
+
const addTool = createAddToBacklogTool(client);
|
|
146
|
+
const addResult = await addTool({
|
|
147
|
+
content: 'Users report competitor app Calm added sleep stories',
|
|
148
|
+
product_slug: 'my-meditation-app',
|
|
149
|
+
source: 'research-agent',
|
|
150
|
+
domain_hint: 'product-research',
|
|
151
|
+
tags: ['competitor', 'feature'],
|
|
152
|
+
});
|
|
153
|
+
expect(calls.addBacklogEntry).toHaveLength(1);
|
|
154
|
+
expect(calls.addBacklogEntry[0]).toEqual({
|
|
155
|
+
content: 'Users report competitor app Calm added sleep stories',
|
|
156
|
+
product_slug: 'my-meditation-app',
|
|
157
|
+
source: 'research-agent',
|
|
158
|
+
domain_hint: 'product-research',
|
|
159
|
+
tags: ['competitor', 'feature'],
|
|
160
|
+
});
|
|
161
|
+
expect(addResult.entry.id).toBe('entry-1');
|
|
162
|
+
expect(addResult.entry.status).toBe('pending');
|
|
163
|
+
// Step 2: List entries — verify pending entry appears
|
|
164
|
+
const listTool = createListBacklogTool(client);
|
|
165
|
+
const listResult = await listTool({
|
|
166
|
+
product_slug: 'my-meditation-app',
|
|
167
|
+
status: 'pending',
|
|
168
|
+
});
|
|
169
|
+
expect(calls.listBacklog).toHaveLength(1);
|
|
170
|
+
expect(calls.listBacklog[0].product_slug).toBe('my-meditation-app');
|
|
171
|
+
expect(calls.listBacklog[0].status).toBe('pending');
|
|
172
|
+
expect(listResult.entries).toHaveLength(1);
|
|
173
|
+
expect(listResult.total).toBe(1);
|
|
174
|
+
// Step 3: Process — verify changes applied
|
|
175
|
+
const processTool = createProcessBacklogTool(client);
|
|
176
|
+
const processResult = await processTool({
|
|
177
|
+
product_slug: 'my-meditation-app',
|
|
178
|
+
dry_run: false,
|
|
179
|
+
});
|
|
180
|
+
expect(calls.processBacklog).toHaveLength(1);
|
|
181
|
+
expect(calls.processBacklog[0].dry_run).toBe(false);
|
|
182
|
+
expect(processResult.applied).toBe(1);
|
|
183
|
+
expect(processResult.errors).toHaveLength(0);
|
|
184
|
+
expect(processResult.details[0].status).toBe('applied');
|
|
185
|
+
expect(processResult.details[0].applied_changes).toEqual([
|
|
186
|
+
{ filepath: 'docs/research/competitors.md', action: 'md_append', product_slug: 'my-meditation-app' },
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
it('adds a global entry and processes with routing', async () => {
|
|
190
|
+
const globalEntry = makeEntry({
|
|
191
|
+
id: 'global-1',
|
|
192
|
+
content: 'App Store review mentions slow load times on iPhone 12',
|
|
193
|
+
source: 'review-monitor',
|
|
194
|
+
});
|
|
195
|
+
const processResponse = {
|
|
196
|
+
routed: 1,
|
|
197
|
+
processed: 1,
|
|
198
|
+
applied: 0,
|
|
199
|
+
rejected: 0,
|
|
200
|
+
partial: 1,
|
|
201
|
+
errors: [],
|
|
202
|
+
details: [{
|
|
203
|
+
id: 'global-1',
|
|
204
|
+
status: 'partial',
|
|
205
|
+
result_summary: 'medium confidence — needs review',
|
|
206
|
+
applied_changes: [{ filepath: 'docs/app-store/feedback.md', action: 'md_append', product_slug: 'my-app' }],
|
|
207
|
+
rejection_reason: null,
|
|
208
|
+
}],
|
|
209
|
+
};
|
|
210
|
+
const { client, calls } = createTrackedClient({
|
|
211
|
+
addBacklogEntry: async () => ({ entry: globalEntry }),
|
|
212
|
+
processBacklog: async () => processResponse,
|
|
213
|
+
});
|
|
214
|
+
// Add global entry (no product_slug)
|
|
215
|
+
const addTool = createAddToBacklogTool(client);
|
|
216
|
+
const addResult = await addTool({
|
|
217
|
+
content: 'App Store review mentions slow load times on iPhone 12',
|
|
218
|
+
source: 'review-monitor',
|
|
219
|
+
});
|
|
220
|
+
expect(addResult.entry.product_slug).toBeNull();
|
|
221
|
+
// Process all (triggers routing + processing)
|
|
222
|
+
const processTool = createProcessBacklogTool(client);
|
|
223
|
+
const processResult = await processTool({ dry_run: true });
|
|
224
|
+
expect(calls.processBacklog[0].product_slug).toBeUndefined();
|
|
225
|
+
expect(calls.processBacklog[0].dry_run).toBe(true);
|
|
226
|
+
expect(processResult.routed).toBe(1);
|
|
227
|
+
expect(processResult.details[0].status).toBe('partial');
|
|
228
|
+
});
|
|
229
|
+
it('dry_run defaults to true and previews without applying', async () => {
|
|
230
|
+
const processResponse = {
|
|
231
|
+
routed: 0,
|
|
232
|
+
processed: 2,
|
|
233
|
+
applied: 0,
|
|
234
|
+
rejected: 0,
|
|
235
|
+
partial: 0,
|
|
236
|
+
errors: [],
|
|
237
|
+
details: [
|
|
238
|
+
{ id: 'e1', status: 'pending', result_summary: '[DRY RUN] high confidence: fits brand config', applied_changes: [{ filepath: 'brand-config.json', action: 'json_patch', product_slug: 'my-app' }], rejection_reason: null },
|
|
239
|
+
{ id: 'e2', status: 'pending', result_summary: '[DRY RUN] medium confidence: could be competitor data', applied_changes: [{ filepath: 'docs/research/competitors.md', action: 'md_append', product_slug: 'my-app' }], rejection_reason: null },
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
const { client, calls } = createTrackedClient({
|
|
243
|
+
processBacklog: async () => processResponse,
|
|
244
|
+
});
|
|
245
|
+
const processTool = createProcessBacklogTool(client);
|
|
246
|
+
// No dry_run passed — should default to true via Zod schema
|
|
247
|
+
const parsed = processBacklogSchema.parse({ product_slug: 'my-app' });
|
|
248
|
+
expect(parsed.dry_run).toBe(true);
|
|
249
|
+
const result = await processTool({ product_slug: 'my-app', dry_run: true });
|
|
250
|
+
expect(calls.processBacklog[0].dry_run).toBe(true);
|
|
251
|
+
expect(result.applied).toBe(0);
|
|
252
|
+
expect(result.details).toHaveLength(2);
|
|
253
|
+
expect(result.details[0].result_summary).toContain('[DRY RUN]');
|
|
254
|
+
});
|
|
255
|
+
});
|
package/dist/tools/products.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ApiClient } from '../api-client.js';
|
|
3
|
-
export declare const listProductsSchema: z.ZodObject<{
|
|
3
|
+
export declare const listProductsSchema: z.ZodObject<{
|
|
4
|
+
search: z.ZodOptional<z.ZodString>;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
search?: string | undefined;
|
|
7
|
+
}, {
|
|
8
|
+
search?: string | undefined;
|
|
9
|
+
}>;
|
|
4
10
|
export declare const getProductSchema: z.ZodObject<{
|
|
5
11
|
slug: z.ZodString;
|
|
12
|
+
domain: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
|
|
6
13
|
}, "strip", z.ZodTypeAny, {
|
|
7
14
|
slug: string;
|
|
15
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
8
16
|
}, {
|
|
9
17
|
slug: string;
|
|
18
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
10
19
|
}>;
|
|
11
20
|
export declare const readProductFileSchema: z.ZodObject<{
|
|
12
21
|
slug: z.ZodString;
|
|
@@ -68,24 +77,75 @@ export declare const pullProductFilesSchema: z.ZodObject<{
|
|
|
68
77
|
slug: z.ZodString;
|
|
69
78
|
output_dir: z.ZodString;
|
|
70
79
|
files: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
80
|
+
include_knowledge: z.ZodOptional<z.ZodBoolean>;
|
|
71
81
|
}, "strip", z.ZodTypeAny, {
|
|
72
82
|
slug: string;
|
|
73
83
|
output_dir: string;
|
|
74
84
|
files?: string[] | undefined;
|
|
85
|
+
include_knowledge?: boolean | undefined;
|
|
75
86
|
}, {
|
|
76
87
|
slug: string;
|
|
77
88
|
output_dir: string;
|
|
78
89
|
files?: string[] | undefined;
|
|
90
|
+
include_knowledge?: boolean | undefined;
|
|
91
|
+
}>;
|
|
92
|
+
export declare const getProductIndexSchema: z.ZodObject<{
|
|
93
|
+
slug: z.ZodString;
|
|
94
|
+
domain: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
|
|
95
|
+
}, "strip", z.ZodTypeAny, {
|
|
96
|
+
slug: string;
|
|
97
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
98
|
+
}, {
|
|
99
|
+
slug: string;
|
|
100
|
+
domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
|
|
101
|
+
}>;
|
|
102
|
+
export declare const getProductHealthSchema: z.ZodObject<{
|
|
103
|
+
slug: z.ZodOptional<z.ZodString>;
|
|
104
|
+
}, "strip", z.ZodTypeAny, {
|
|
105
|
+
slug?: string | undefined;
|
|
106
|
+
}, {
|
|
107
|
+
slug?: string | undefined;
|
|
108
|
+
}>;
|
|
109
|
+
export declare const createProductSchema: z.ZodObject<{
|
|
110
|
+
name: z.ZodString;
|
|
111
|
+
slug: z.ZodOptional<z.ZodString>;
|
|
112
|
+
description: z.ZodOptional<z.ZodString>;
|
|
113
|
+
}, "strip", z.ZodTypeAny, {
|
|
114
|
+
name: string;
|
|
115
|
+
slug?: string | undefined;
|
|
116
|
+
description?: string | undefined;
|
|
117
|
+
}, {
|
|
118
|
+
name: string;
|
|
119
|
+
slug?: string | undefined;
|
|
120
|
+
description?: string | undefined;
|
|
121
|
+
}>;
|
|
122
|
+
export declare const deleteProductSchema: z.ZodObject<{
|
|
123
|
+
slug: z.ZodString;
|
|
124
|
+
}, "strip", z.ZodTypeAny, {
|
|
125
|
+
slug: string;
|
|
126
|
+
}, {
|
|
127
|
+
slug: string;
|
|
128
|
+
}>;
|
|
129
|
+
export declare const deleteProductFileSchema: z.ZodObject<{
|
|
130
|
+
slug: z.ZodString;
|
|
131
|
+
filepath: z.ZodString;
|
|
132
|
+
}, "strip", z.ZodTypeAny, {
|
|
133
|
+
slug: string;
|
|
134
|
+
filepath: string;
|
|
135
|
+
}, {
|
|
136
|
+
slug: string;
|
|
137
|
+
filepath: string;
|
|
79
138
|
}>;
|
|
80
|
-
export declare function createListProductsTool(client: ApiClient): (
|
|
139
|
+
export declare function createListProductsTool(client: ApiClient): (input: z.infer<typeof listProductsSchema>) => Promise<{
|
|
81
140
|
products: Array<{
|
|
82
141
|
id: string;
|
|
83
142
|
slug: string;
|
|
84
143
|
name: string;
|
|
85
144
|
description: string | null;
|
|
86
|
-
|
|
87
|
-
|
|
145
|
+
created_at: string;
|
|
146
|
+
updated_at: string;
|
|
88
147
|
}>;
|
|
148
|
+
total: number;
|
|
89
149
|
}>;
|
|
90
150
|
export declare function createGetProductTool(client: ApiClient): (input: z.infer<typeof getProductSchema>) => Promise<{
|
|
91
151
|
product: {
|
|
@@ -97,10 +157,16 @@ export declare function createGetProductTool(client: ApiClient): (input: z.infer
|
|
|
97
157
|
updated_at: string;
|
|
98
158
|
};
|
|
99
159
|
knowledge: Record<string, unknown>;
|
|
160
|
+
doc_files: Array<{
|
|
161
|
+
path: string;
|
|
162
|
+
size: number;
|
|
163
|
+
domain: string;
|
|
164
|
+
}>;
|
|
100
165
|
binary_files: Array<{
|
|
101
166
|
path: string;
|
|
102
167
|
size: number;
|
|
103
168
|
content_type: string;
|
|
169
|
+
domain: string;
|
|
104
170
|
}>;
|
|
105
171
|
}>;
|
|
106
172
|
export declare function createReadProductFileTool(client: ApiClient): (input: z.infer<typeof readProductFileSchema>) => Promise<{
|
|
@@ -119,10 +185,6 @@ export declare function createUploadProductFilesTool(client: ApiClient): (input:
|
|
|
119
185
|
uploaded: string[];
|
|
120
186
|
}>;
|
|
121
187
|
export declare function createPullProductFilesTool(client: ApiClient): (input: z.infer<typeof pullProductFilesSchema>) => Promise<{
|
|
122
|
-
product: {
|
|
123
|
-
name: string;
|
|
124
|
-
slug: string;
|
|
125
|
-
};
|
|
126
188
|
files_pulled: number;
|
|
127
189
|
output_dir: string;
|
|
128
190
|
files: {
|
|
@@ -130,3 +192,50 @@ export declare function createPullProductFilesTool(client: ApiClient): (input: z
|
|
|
130
192
|
size: number;
|
|
131
193
|
}[];
|
|
132
194
|
}>;
|
|
195
|
+
export declare function createGetProductIndexTool(client: ApiClient): (input: z.infer<typeof getProductIndexSchema>) => Promise<{
|
|
196
|
+
product_slug: string;
|
|
197
|
+
updated_at: string;
|
|
198
|
+
files: Array<{
|
|
199
|
+
path: string;
|
|
200
|
+
title: string;
|
|
201
|
+
summary: string;
|
|
202
|
+
sections: string[];
|
|
203
|
+
domain: string;
|
|
204
|
+
content_type: string;
|
|
205
|
+
size: number;
|
|
206
|
+
updated_at: string;
|
|
207
|
+
}>;
|
|
208
|
+
}>;
|
|
209
|
+
export declare function createGetProductHealthTool(client: ApiClient): (input: z.infer<typeof getProductHealthSchema>) => Promise<{
|
|
210
|
+
slug: string;
|
|
211
|
+
name: string;
|
|
212
|
+
overall_score: number;
|
|
213
|
+
files: Record<string, unknown>;
|
|
214
|
+
recommendations: string[];
|
|
215
|
+
} | {
|
|
216
|
+
products: Array<{
|
|
217
|
+
slug: string;
|
|
218
|
+
name: string;
|
|
219
|
+
overall_score: number;
|
|
220
|
+
files: Record<string, {
|
|
221
|
+
score: number;
|
|
222
|
+
missing_required: string[];
|
|
223
|
+
}>;
|
|
224
|
+
}>;
|
|
225
|
+
}>;
|
|
226
|
+
export declare function createCreateProductTool(client: ApiClient): (input: z.infer<typeof createProductSchema>) => Promise<{
|
|
227
|
+
product: {
|
|
228
|
+
id: string;
|
|
229
|
+
slug: string;
|
|
230
|
+
name: string;
|
|
231
|
+
description: string | null;
|
|
232
|
+
created_at: string;
|
|
233
|
+
updated_at: string;
|
|
234
|
+
};
|
|
235
|
+
}>;
|
|
236
|
+
export declare function createDeleteProductTool(client: ApiClient): (input: z.infer<typeof deleteProductSchema>) => Promise<{
|
|
237
|
+
success: boolean;
|
|
238
|
+
}>;
|
|
239
|
+
export declare function createDeleteProductFileTool(client: ApiClient): (input: z.infer<typeof deleteProductFileSchema>) => Promise<{
|
|
240
|
+
success: boolean;
|
|
241
|
+
}>;
|
package/dist/tools/products.js
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import { getMimeType } from './deploy.js';
|
|
4
5
|
// --- Schemas ---
|
|
5
|
-
export const listProductsSchema = z.object({
|
|
6
|
+
export const listProductsSchema = z.object({
|
|
7
|
+
search: z.string().optional().describe('Search term to filter products by name or description'),
|
|
8
|
+
});
|
|
9
|
+
const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
|
|
6
10
|
export const getProductSchema = z.object({
|
|
7
11
|
slug: z.string().describe('Product slug'),
|
|
12
|
+
domain: z.enum(DOMAIN_VALUES).optional().describe('Filter files by domain'),
|
|
8
13
|
});
|
|
9
14
|
export const readProductFileSchema = z.object({
|
|
10
15
|
slug: z.string().describe('Product slug'),
|
|
11
|
-
filepath: z.string().describe('File path within the product (e.g., "meta-ads.json", "brand-assets/logo.png")'),
|
|
16
|
+
filepath: z.string().describe('File path within the product (e.g., "docs/research/user-research.md", "meta-ads.json", "brand-assets/logo.png")'),
|
|
12
17
|
});
|
|
13
18
|
export const updateProductKnowledgeSchema = z.object({
|
|
14
19
|
slug: z.string().describe('Product slug'),
|
|
15
|
-
filepath: z.string().describe('
|
|
20
|
+
filepath: z.string().describe('Config file to update (e.g., "meta-ads.json", "cpps.json", "brand-config.json")'),
|
|
16
21
|
content: z.record(z.unknown()).describe('JSON object to deep merge-patch into the knowledge file. Use null values to delete keys.'),
|
|
17
22
|
updated_by: z.string().optional().describe('Agent name or ID writing the update'),
|
|
18
23
|
});
|
|
19
24
|
export const uploadProductFilesSchema = z.object({
|
|
20
25
|
slug: z.string().describe('Product slug'),
|
|
21
26
|
files: z.array(z.object({
|
|
22
|
-
path: z.string().describe('File path within the product (e.g., "brand-assets/logo.png")'),
|
|
27
|
+
path: z.string().describe('File path within the product (e.g., "docs/research/user-research.md", "brand-assets/logo.png")'),
|
|
23
28
|
content: z.string().describe('File content as text or base64 data URI (e.g., "data:image/png;base64,...")'),
|
|
24
29
|
content_type: z.string().optional().describe('MIME type (auto-detected if not provided)'),
|
|
25
30
|
})).describe('Files to upload'),
|
|
@@ -27,18 +32,38 @@ export const uploadProductFilesSchema = z.object({
|
|
|
27
32
|
export const pullProductFilesSchema = z.object({
|
|
28
33
|
slug: z.string().describe('Product slug'),
|
|
29
34
|
output_dir: z.string().describe('Local directory to write files to. Will be created if needed.'),
|
|
30
|
-
files: z.array(z.string()).optional().describe('Specific file paths to pull. If omitted, all binary files are pulled.'),
|
|
35
|
+
files: z.array(z.string()).optional().describe('Specific file paths to pull (e.g., ["docs/research/roadmap.md", "brand-assets/logo.png"]). If omitted, all binary files are pulled.'),
|
|
36
|
+
include_knowledge: z.boolean().optional().describe('Include knowledge JSON files in addition to binary files. Default: false.'),
|
|
37
|
+
});
|
|
38
|
+
export const getProductIndexSchema = z.object({
|
|
39
|
+
slug: z.string().describe('Product slug'),
|
|
40
|
+
domain: z.enum(DOMAIN_VALUES).optional().describe('Filter index entries by domain'),
|
|
41
|
+
});
|
|
42
|
+
export const getProductHealthSchema = z.object({
|
|
43
|
+
slug: z.string().optional().describe('Product slug. If omitted, returns health for all products.'),
|
|
44
|
+
});
|
|
45
|
+
export const createProductSchema = z.object({
|
|
46
|
+
name: z.string().describe('Product name'),
|
|
47
|
+
slug: z.string().optional().describe('URL-friendly slug (auto-generated from name if not provided)'),
|
|
48
|
+
description: z.string().optional().describe('Product description'),
|
|
49
|
+
});
|
|
50
|
+
export const deleteProductSchema = z.object({
|
|
51
|
+
slug: z.string().describe('Product slug'),
|
|
52
|
+
});
|
|
53
|
+
export const deleteProductFileSchema = z.object({
|
|
54
|
+
slug: z.string().describe('Product slug'),
|
|
55
|
+
filepath: z.string().describe('File path within the product to delete (e.g., "docs/copy/old-brief.md", "brand-assets/old-logo.png")'),
|
|
31
56
|
});
|
|
32
57
|
// --- Tool factories ---
|
|
33
58
|
export function createListProductsTool(client) {
|
|
34
|
-
return async (
|
|
35
|
-
const result = await client.listProducts();
|
|
59
|
+
return async (input) => {
|
|
60
|
+
const result = await client.listProducts(input.search ? { search: input.search } : undefined);
|
|
36
61
|
return result;
|
|
37
62
|
};
|
|
38
63
|
}
|
|
39
64
|
export function createGetProductTool(client) {
|
|
40
65
|
return async (input) => {
|
|
41
|
-
const result = await client.getProduct(input.slug);
|
|
66
|
+
const result = await client.getProduct(input.slug, input.domain);
|
|
42
67
|
return result;
|
|
43
68
|
};
|
|
44
69
|
}
|
|
@@ -64,13 +89,7 @@ export function createUploadProductFilesTool(client) {
|
|
|
64
89
|
if (content.startsWith('/') || content.startsWith('./') || content.startsWith('../')) {
|
|
65
90
|
const filePath = path.resolve(content);
|
|
66
91
|
const buffer = await fs.readFile(filePath);
|
|
67
|
-
const
|
|
68
|
-
const mimeTypes = {
|
|
69
|
-
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
70
|
-
gif: 'image/gif', webp: 'image/webp', pdf: 'application/pdf',
|
|
71
|
-
mp4: 'video/mp4', mov: 'video/quicktime', svg: 'image/svg+xml',
|
|
72
|
-
};
|
|
73
|
-
const mimeType = file.content_type || mimeTypes[ext] || 'application/octet-stream';
|
|
92
|
+
const mimeType = file.content_type || getMimeType(filePath);
|
|
74
93
|
const base64 = buffer.toString('base64');
|
|
75
94
|
content = `data:${mimeType};base64,${base64}`;
|
|
76
95
|
}
|
|
@@ -82,43 +101,69 @@ export function createUploadProductFilesTool(client) {
|
|
|
82
101
|
}
|
|
83
102
|
export function createPullProductFilesTool(client) {
|
|
84
103
|
return async (input) => {
|
|
85
|
-
const {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return { product: { name: product.name, slug: product.slug }, files_pulled: 0, output_dir: input.output_dir, files: [] };
|
|
104
|
+
const { files } = await client.pullProductFiles(input.slug, {
|
|
105
|
+
files: input.files,
|
|
106
|
+
include_knowledge: input.include_knowledge,
|
|
107
|
+
});
|
|
108
|
+
if (files.length === 0) {
|
|
109
|
+
return { files_pulled: 0, output_dir: input.output_dir, files: [] };
|
|
92
110
|
}
|
|
93
111
|
const outputDir = path.resolve(input.output_dir);
|
|
94
112
|
await fs.mkdir(outputDir, { recursive: true });
|
|
95
113
|
const written = [];
|
|
96
114
|
const resolvedOutputDir = path.resolve(outputDir);
|
|
97
|
-
for (const
|
|
98
|
-
const
|
|
99
|
-
const destPath = path.join(outputDir, filePath);
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
const destPath = path.join(outputDir, file.path);
|
|
100
117
|
const resolvedDestPath = path.resolve(destPath);
|
|
101
118
|
if (!resolvedDestPath.startsWith(resolvedOutputDir + path.sep) && resolvedDestPath !== resolvedOutputDir) {
|
|
102
|
-
throw new Error(`Unsafe file path rejected: ${
|
|
119
|
+
throw new Error(`Unsafe file path rejected: ${file.path}`);
|
|
103
120
|
}
|
|
104
121
|
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
105
|
-
const content =
|
|
122
|
+
const content = file.content;
|
|
106
123
|
if (content.startsWith('data:')) {
|
|
107
124
|
const commaIndex = content.indexOf(',');
|
|
125
|
+
if (commaIndex === -1)
|
|
126
|
+
throw new Error(`Invalid data URI format for file: ${file.path}`);
|
|
108
127
|
const base64Data = content.slice(commaIndex + 1);
|
|
109
128
|
await fs.writeFile(destPath, Buffer.from(base64Data, 'base64'));
|
|
110
|
-
written.push({ path: filePath, size: result.size || 0 });
|
|
111
129
|
}
|
|
112
130
|
else {
|
|
113
131
|
await fs.writeFile(destPath, content, 'utf-8');
|
|
114
|
-
written.push({ path: filePath, size: content.length });
|
|
115
132
|
}
|
|
133
|
+
written.push({ path: file.path, size: file.size });
|
|
116
134
|
}
|
|
117
135
|
return {
|
|
118
|
-
product: { name: product.name, slug: product.slug },
|
|
119
136
|
files_pulled: written.length,
|
|
120
137
|
output_dir: outputDir,
|
|
121
138
|
files: written,
|
|
122
139
|
};
|
|
123
140
|
};
|
|
124
141
|
}
|
|
142
|
+
export function createGetProductIndexTool(client) {
|
|
143
|
+
return async (input) => {
|
|
144
|
+
return client.getProductIndex(input.slug, input.domain);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export function createGetProductHealthTool(client) {
|
|
148
|
+
return async (input) => {
|
|
149
|
+
if (input.slug) {
|
|
150
|
+
return client.getProductHealth(input.slug);
|
|
151
|
+
}
|
|
152
|
+
return client.getAllProductsHealth();
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function createCreateProductTool(client) {
|
|
156
|
+
return async (input) => {
|
|
157
|
+
return client.createProduct(input);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export function createDeleteProductTool(client) {
|
|
161
|
+
return async (input) => {
|
|
162
|
+
return client.deleteProduct(input.slug);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export function createDeleteProductFileTool(client) {
|
|
166
|
+
return async (input) => {
|
|
167
|
+
return client.deleteProductFile(input.slug, input.filepath);
|
|
168
|
+
};
|
|
169
|
+
}
|