@taiga-ai/mcp-server 0.6.0-rc.393 → 0.7.0-dev.395
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/index.js +1151 -128
- package/package.json +1 -1
- package/src/client.test.ts +19 -0
- package/src/client.ts +11 -3
- package/src/config.test.ts +1 -1
- package/src/config.ts +4 -1
- package/src/index.ts +3 -30
- package/src/server.integration.test.ts +644 -9
- package/src/tools/catalogue.test.ts +109 -0
- package/src/tools/catalogue.ts +165 -0
- package/src/tools/documents.ts +115 -35
- package/src/tools/instructions.ts +65 -0
- package/src/tools/products.ts +234 -43
- package/src/tools/sections.test.ts +34 -1
- package/src/tools/sections.ts +23 -1
- package/src/tools/summarize.ts +3 -34
- package/taiga-ai-mcp-server-0.7.0-dev.395.tgz +0 -0
- package/src/tools/inputs.test.ts +0 -19
- package/src/tools/inputs.ts +0 -13
- package/taiga-ai-mcp-server-0.6.0-rc.393.tgz +0 -0
package/src/tools/products.ts
CHANGED
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type
|
|
3
|
+
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
4
|
import { READ_ONLY } from './annotations.js';
|
|
5
|
+
import {
|
|
6
|
+
ORIGIN_MEANING,
|
|
7
|
+
PRODUCT_DOCUMENT_ORIGINS,
|
|
8
|
+
PRODUCT_DOCUMENT_TYPES,
|
|
9
|
+
PRODUCT_STATUSES,
|
|
10
|
+
defaultOriginFor,
|
|
11
|
+
emptyOriginNote,
|
|
12
|
+
isImportedProduct,
|
|
13
|
+
productDocumentName,
|
|
14
|
+
type ProductDocumentOrigin,
|
|
15
|
+
type ProductSettingsView,
|
|
16
|
+
} from './catalogue.js';
|
|
5
17
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
6
|
-
import { documentType } from './inputs.js';
|
|
7
18
|
import { parseRemote } from './remote.js';
|
|
8
|
-
import {
|
|
19
|
+
import { stripProvenance } from './sections.js';
|
|
20
|
+
import { pickFields } from './summarize.js';
|
|
9
21
|
|
|
10
22
|
/** What a product row needs to say for the model to pick one and read it in full. */
|
|
11
23
|
const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'kind', 'discoveryStep'] as const;
|
|
12
24
|
|
|
25
|
+
/**
|
|
26
|
+
* What a document row needs to say for the model to pick one and read it in
|
|
27
|
+
* full: which document, on which timeline, how current. Not the derived-from
|
|
28
|
+
* tree, the data key count or the reviewer's name.
|
|
29
|
+
*/
|
|
30
|
+
const DOCUMENT_SUMMARY_FIELDS = [
|
|
31
|
+
'type',
|
|
32
|
+
'origin',
|
|
33
|
+
'version',
|
|
34
|
+
'isDraft',
|
|
35
|
+
'hasDraft',
|
|
36
|
+
'stage',
|
|
37
|
+
'reviewedAt',
|
|
38
|
+
'updatedAt',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
13
41
|
/** The API's page ceiling (paginationSchema in apps/api). */
|
|
14
42
|
const PAGE_SIZE = 100;
|
|
15
43
|
|
|
@@ -20,6 +48,20 @@ const PAGE_SIZE = 100;
|
|
|
20
48
|
*/
|
|
21
49
|
const RESOLVE_PAGE_LIMIT = 10;
|
|
22
50
|
|
|
51
|
+
const productId = z
|
|
52
|
+
.string()
|
|
53
|
+
.uuid()
|
|
54
|
+
.describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products');
|
|
55
|
+
|
|
56
|
+
const originInput = z
|
|
57
|
+
.enum(PRODUCT_DOCUMENT_ORIGINS)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
`Which timeline. ${ORIGIN_MEANING.plan} ${ORIGIN_MEANING.reality} ` +
|
|
61
|
+
"Omit it to read the product's own timeline: reality for an imported product, plan for every other, " +
|
|
62
|
+
'and always plan for the documents a person writes (Look & Feel, Product Instructions).'
|
|
63
|
+
);
|
|
64
|
+
|
|
23
65
|
/**
|
|
24
66
|
* Every product linked to the repository, page by page, until the API runs
|
|
25
67
|
* out or the cap is hit. A page that fails fails the call, like every other
|
|
@@ -37,6 +79,128 @@ async function linkedProducts(client: TaigaClient, repository: string, repositor
|
|
|
37
79
|
return { products, truncated: true };
|
|
38
80
|
}
|
|
39
81
|
|
|
82
|
+
/** A document list row as the model sees it: the summary fields plus a display name and the staleness fact. */
|
|
83
|
+
function documentSummaries(rows: unknown[]): Record<string, unknown>[] {
|
|
84
|
+
const typed = rows as Record<string, unknown>[];
|
|
85
|
+
return pickFields(typed, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
|
|
86
|
+
// `outdated` on the wire is the list of sources that moved on; the model
|
|
87
|
+
// needs the fact, not the tree. Published plan rows carry it: null when
|
|
88
|
+
// current, a source list when not. A draft has nothing published to be
|
|
89
|
+
// outdated (the API stamps it null too) and reality rows carry no such
|
|
90
|
+
// field: no fact for either.
|
|
91
|
+
const outdated = typed[index]!.outdated as { sources?: unknown[] } | null | undefined;
|
|
92
|
+
const fact =
|
|
93
|
+
picked.isDraft !== false
|
|
94
|
+
? undefined
|
|
95
|
+
: outdated === null
|
|
96
|
+
? false
|
|
97
|
+
: Array.isArray(outdated?.sources)
|
|
98
|
+
? outdated.sources.length > 0
|
|
99
|
+
: undefined;
|
|
100
|
+
return {
|
|
101
|
+
type: picked.type,
|
|
102
|
+
name: productDocumentName(String(picked.type)),
|
|
103
|
+
...picked,
|
|
104
|
+
...(fact === undefined ? {} : { outdated: fact }),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface Timelines {
|
|
110
|
+
projectType: 'greenfield' | 'brownfield';
|
|
111
|
+
defaultOrigin: ProductDocumentOrigin;
|
|
112
|
+
/** The side's summaries, or null when the API could not list it (see `unlistable`). */
|
|
113
|
+
plan: Record<string, unknown>[] | null;
|
|
114
|
+
reality: Record<string, unknown>[] | null;
|
|
115
|
+
/** Why a null side is null, in the words the notes and a miss message use. */
|
|
116
|
+
unlistable: Partial<Record<ProductDocumentOrigin, string>>;
|
|
117
|
+
notes: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The one sentence for a side the API would not list, with what it said. */
|
|
121
|
+
function unlistableNote(origin: ProductDocumentOrigin, reason: unknown): string {
|
|
122
|
+
const said = reason instanceof TaigaApiError ? `${reason.status} ${reason.code ?? ''}`.trim() : 'the request failed';
|
|
123
|
+
return `The ${origin} side could not be listed (${said}).`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Both timelines of a product's documents, each a list of summaries, with
|
|
128
|
+
* which one is the product's own and a note for an empty side. The product
|
|
129
|
+
* decides what an empty side means (imported or not, how far its import got),
|
|
130
|
+
* so a caller that already holds it passes it in.
|
|
131
|
+
*/
|
|
132
|
+
async function documentTimelines(client: TaigaClient, id: string, product?: ProductSettingsView): Promise<Timelines> {
|
|
133
|
+
const loaded = product ?? ((await client.getProduct(id)).data as ProductSettingsView);
|
|
134
|
+
// Each side on its own: one side the API cannot list is reported as
|
|
135
|
+
// unreadable, not as empty, and never hides the side that did answer.
|
|
136
|
+
// Both failing is the API failing, and that is an error like any other.
|
|
137
|
+
const settled = await Promise.allSettled(
|
|
138
|
+
PRODUCT_DOCUMENT_ORIGINS.map((o) => client.listProductDocuments(id, { origin: o, limit: PAGE_SIZE }))
|
|
139
|
+
);
|
|
140
|
+
if (settled.every((result) => result.status === 'rejected')) throw settled[0]!.reason;
|
|
141
|
+
const sides: Partial<Record<ProductDocumentOrigin, Record<string, unknown>[] | null>> = {};
|
|
142
|
+
const unlistable: Partial<Record<ProductDocumentOrigin, string>> = {};
|
|
143
|
+
PRODUCT_DOCUMENT_ORIGINS.forEach((o, index) => {
|
|
144
|
+
const result = settled[index]!;
|
|
145
|
+
if (result.status === 'fulfilled') sides[o] = documentSummaries(result.value.data);
|
|
146
|
+
else {
|
|
147
|
+
sides[o] = null;
|
|
148
|
+
unlistable[o] = unlistableNote(o, result.reason);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const notes = PRODUCT_DOCUMENT_ORIGINS.flatMap((o) => {
|
|
152
|
+
const side = sides[o];
|
|
153
|
+
if (!side) return [unlistable[o]!];
|
|
154
|
+
return side.length === 0 ? [emptyOriginNote(o, loaded)] : [];
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
projectType: isImportedProduct(loaded) ? 'brownfield' : 'greenfield',
|
|
158
|
+
defaultOrigin: defaultOriginFor(loaded),
|
|
159
|
+
plan: sides.plan ?? null,
|
|
160
|
+
reality: sides.reality ?? null,
|
|
161
|
+
unlistable,
|
|
162
|
+
notes,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const isPublished = (row: Record<string, unknown>) => row.isDraft === false;
|
|
167
|
+
|
|
168
|
+
/** `specification (v2), architecture (draft)` for one side. */
|
|
169
|
+
function sideList(rows: Record<string, unknown>[]): string {
|
|
170
|
+
return rows
|
|
171
|
+
.map((row) => `${String(row.type)} (${isPublished(row) ? `v${String(row.version)}` : 'draft'})`)
|
|
172
|
+
.join(', ');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What a missed document read should say: where the type does live, and what each side holds. */
|
|
176
|
+
function describeMiss(
|
|
177
|
+
type: string,
|
|
178
|
+
wanted: ProductDocumentOrigin,
|
|
179
|
+
timelines: Timelines,
|
|
180
|
+
product: ProductSettingsView
|
|
181
|
+
): string {
|
|
182
|
+
const name = productDocumentName(type);
|
|
183
|
+
const other: ProductDocumentOrigin = wanted === 'plan' ? 'reality' : 'plan';
|
|
184
|
+
const parts = [`No published ${wanted} ${name} (${type}) for this product.`];
|
|
185
|
+
const rows = (side: ProductDocumentOrigin) => timelines[side] ?? [];
|
|
186
|
+
if (rows(other).some((row) => row.type === type && isPublished(row))) {
|
|
187
|
+
parts.push(`It is published on the ${other} side: call again with origin '${other}'.`);
|
|
188
|
+
} else {
|
|
189
|
+
for (const side of [wanted, other]) {
|
|
190
|
+
if (rows(side).some((row) => row.type === type && !isPublished(row))) {
|
|
191
|
+
parts.push(`A draft of it exists on the ${side} side, unpublished.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const o of PRODUCT_DOCUMENT_ORIGINS) {
|
|
196
|
+
const side = timelines[o];
|
|
197
|
+
const label = `${o[0]!.toUpperCase()}${o.slice(1)} documents`;
|
|
198
|
+
if (side === null) parts.push(timelines.unlistable[o]!);
|
|
199
|
+
else parts.push(side.length > 0 ? `${label}: ${sideList(side)}.` : emptyOriginNote(o, product));
|
|
200
|
+
}
|
|
201
|
+
return parts.join(' ');
|
|
202
|
+
}
|
|
203
|
+
|
|
40
204
|
export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
41
205
|
if (client.hasCapability('factories:read')) {
|
|
42
206
|
server.registerTool(
|
|
@@ -67,9 +231,9 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
67
231
|
{
|
|
68
232
|
title: 'List products',
|
|
69
233
|
description:
|
|
70
|
-
'List products in the organization
|
|
71
|
-
'taiga_resolve_product finds it from
|
|
72
|
-
'
|
|
234
|
+
'List products in the organization, as summaries (id, name, slug, status, factory, kind). ' +
|
|
235
|
+
'Use this to find a product by name; inside a git checkout, taiga_resolve_product finds it from ' +
|
|
236
|
+
'the remote URL instead. taiga_get_product reads one in full.',
|
|
73
237
|
inputSchema: {
|
|
74
238
|
factoryId: z
|
|
75
239
|
.string()
|
|
@@ -77,12 +241,11 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
77
241
|
.optional()
|
|
78
242
|
.describe('Filter by factory ID -- get factory IDs from taiga_list_factories'),
|
|
79
243
|
status: z
|
|
80
|
-
.enum(
|
|
244
|
+
.enum(PRODUCT_STATUSES)
|
|
81
245
|
.optional()
|
|
82
246
|
.describe(
|
|
83
|
-
'Filter by status
|
|
84
|
-
'
|
|
85
|
-
'Omit this unless you are working with data set through the API directly.'
|
|
247
|
+
'Filter by status: draft until the product finishes setup in Taiga, active after that, ' +
|
|
248
|
+
'archived when retired.'
|
|
86
249
|
),
|
|
87
250
|
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
88
251
|
},
|
|
@@ -100,21 +263,37 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
100
263
|
{
|
|
101
264
|
title: 'Get product',
|
|
102
265
|
description:
|
|
103
|
-
'Get one product in full: name, slug, factory, what Taiga owns for it
|
|
104
|
-
'deployment) and its settings; the list and resolve tools carry
|
|
266
|
+
'Get one product in full, by ID or by slug: name, slug, factory, what Taiga owns for it ' +
|
|
267
|
+
'(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
|
|
268
|
+
'summaries. Use taiga_list_product_documents for its specification and design, ' +
|
|
105
269
|
'taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did.',
|
|
106
270
|
inputSchema: {
|
|
107
|
-
productId:
|
|
271
|
+
productId: productId.optional(),
|
|
272
|
+
slug: z
|
|
108
273
|
.string()
|
|
109
|
-
.
|
|
110
|
-
.
|
|
274
|
+
.max(63)
|
|
275
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'A slug is lowercase words joined by hyphens')
|
|
276
|
+
.optional()
|
|
277
|
+
.describe('Product slug, as it appears in Taiga URLs -- an alternative to productId'),
|
|
111
278
|
},
|
|
112
279
|
annotations: READ_ONLY,
|
|
113
280
|
},
|
|
114
281
|
(params) =>
|
|
115
282
|
runTool(async () => {
|
|
116
|
-
|
|
117
|
-
|
|
283
|
+
if (params.productId && params.slug) {
|
|
284
|
+
return toolError('VALIDATION_FAILED', 'Pass productId or slug, not both: they could name two products.');
|
|
285
|
+
}
|
|
286
|
+
if (params.productId) return toolResult((await client.getProduct(params.productId)).data);
|
|
287
|
+
if (!params.slug) return toolError('VALIDATION_FAILED', 'Pass productId or slug.');
|
|
288
|
+
const result = await client.listProducts({ slug: params.slug, limit: 1 });
|
|
289
|
+
const [product] = result.data;
|
|
290
|
+
if (!product) {
|
|
291
|
+
return toolError(
|
|
292
|
+
'NOT_FOUND',
|
|
293
|
+
`No product you can see has the slug "${params.slug}". taiga_list_products shows the ones you can.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return toolResult(product);
|
|
118
297
|
})
|
|
119
298
|
);
|
|
120
299
|
|
|
@@ -168,22 +347,20 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
168
347
|
{
|
|
169
348
|
title: 'List product documents',
|
|
170
349
|
description:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
'
|
|
175
|
-
'Use taiga_get_product_document to read
|
|
176
|
-
inputSchema: {
|
|
177
|
-
productId: z.string().uuid().describe('Product ID -- get from taiga_list_products'),
|
|
178
|
-
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
179
|
-
},
|
|
350
|
+
"List a product's documents on both timelines, as summaries (type, name, version, draft state, " +
|
|
351
|
+
"review and update times), and say which timeline is the product's own (defaultOrigin). " +
|
|
352
|
+
`Two timelines: ${ORIGIN_MEANING.plan} ${ORIGIN_MEANING.reality} ` +
|
|
353
|
+
'An empty side comes with a note saying why; a side the API could not list is null, with a note. ' +
|
|
354
|
+
'Use taiga_get_product_document with the type and origin to read one in full.',
|
|
355
|
+
inputSchema: { productId },
|
|
180
356
|
annotations: READ_ONLY,
|
|
181
357
|
},
|
|
182
358
|
(params) =>
|
|
183
359
|
runTool(async () => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
360
|
+
// `unlistable` is how a miss message is built; the list says the
|
|
361
|
+
// same in its notes and a null side.
|
|
362
|
+
const { unlistable: _unlistable, ...timelines } = await documentTimelines(client, params.productId);
|
|
363
|
+
return toolResult(timelines);
|
|
187
364
|
})
|
|
188
365
|
);
|
|
189
366
|
|
|
@@ -192,26 +369,40 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
192
369
|
{
|
|
193
370
|
title: 'Get product document',
|
|
194
371
|
description:
|
|
195
|
-
'Get the full content of
|
|
196
|
-
'
|
|
197
|
-
'
|
|
198
|
-
|
|
199
|
-
'
|
|
200
|
-
'privacy_assessment, threat_model, risk_register, user_flow, service_blueprint, ' +
|
|
201
|
-
"ui_direction, codebase_snapshot, and product_instructions (the product's standing instructions).",
|
|
372
|
+
'Get the full content of one product document from one timeline. ' +
|
|
373
|
+
'Read the plan to learn what to build: specification (requirements), architecture (design), ' +
|
|
374
|
+
'threat_model (security risks), technology_decisions. Read reality, when the product has it, to ' +
|
|
375
|
+
"learn what the code actually does before changing it. Omit origin for the product's own timeline. " +
|
|
376
|
+
'A read that finds nothing says which documents and timelines the product does have.',
|
|
202
377
|
inputSchema: {
|
|
203
|
-
productId
|
|
204
|
-
type:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
378
|
+
productId,
|
|
379
|
+
type: z
|
|
380
|
+
.enum(PRODUCT_DOCUMENT_TYPES)
|
|
381
|
+
.describe('Document type -- taiga_list_product_documents shows which ones the product has'),
|
|
382
|
+
origin: originInput,
|
|
208
383
|
},
|
|
209
384
|
annotations: READ_ONLY,
|
|
210
385
|
},
|
|
211
386
|
(params) =>
|
|
212
387
|
runTool(async () => {
|
|
213
|
-
|
|
214
|
-
|
|
388
|
+
// The product's own timeline when none is asked for; the API's
|
|
389
|
+
// default is plan, which is the wrong side of an imported product.
|
|
390
|
+
let product: ProductSettingsView | undefined;
|
|
391
|
+
if (!params.origin) product = (await client.getProduct(params.productId)).data as ProductSettingsView;
|
|
392
|
+
const origin = params.origin ?? defaultOriginFor(product, params.type);
|
|
393
|
+
try {
|
|
394
|
+
const result = await client.getProductDocument(params.productId, params.type, origin);
|
|
395
|
+
return toolResult(stripProvenance(result.data as Record<string, unknown>));
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
|
|
398
|
+
// A product the key cannot see is a product miss, not a document
|
|
399
|
+
// miss, so the product read propagates. The lists after it are
|
|
400
|
+
// best effort per side (documentTimelines); only both failing
|
|
401
|
+
// propagates, as the API failing.
|
|
402
|
+
product ??= (await client.getProduct(params.productId)).data as ProductSettingsView;
|
|
403
|
+
const timelines = await documentTimelines(client, params.productId, product);
|
|
404
|
+
return toolError('NOT_FOUND', describeMiss(params.type, origin, timelines, product));
|
|
405
|
+
}
|
|
215
406
|
})
|
|
216
407
|
);
|
|
217
408
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { applySectionEdit, sectionContent, sectionsOf } from './sections.js';
|
|
2
|
+
import { applySectionEdit, sectionContent, sectionsOf, stripProvenance } from './sections.js';
|
|
3
3
|
|
|
4
4
|
describe('sectionsOf', () => {
|
|
5
5
|
it('returns the sections map of a section-shaped document', () => {
|
|
@@ -56,3 +56,36 @@ describe('applySectionEdit', () => {
|
|
|
56
56
|
expect(sections.summary.content).toBe('old');
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
describe('stripProvenance', () => {
|
|
61
|
+
it('drops the derived-from tree and outdated sources at the top and in every section', () => {
|
|
62
|
+
const doc = {
|
|
63
|
+
id: 'd1',
|
|
64
|
+
type: 'policy_security',
|
|
65
|
+
derivedFrom: { sources: [{ type: 'business_goals', version: 2 }] },
|
|
66
|
+
outdated: { sources: [] },
|
|
67
|
+
data: {
|
|
68
|
+
version: '1.0.0',
|
|
69
|
+
sections: {
|
|
70
|
+
summary: { source: 'template', priority: 'core', content: 'x', derivedFrom: ['business_goals'] },
|
|
71
|
+
legacy: 'bare text',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
expect(stripProvenance(doc)).toEqual({
|
|
77
|
+
id: 'd1',
|
|
78
|
+
type: 'policy_security',
|
|
79
|
+
data: {
|
|
80
|
+
version: '1.0.0',
|
|
81
|
+
sections: { summary: { source: 'template', priority: 'core', content: 'x' }, legacy: 'bare text' },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('leaves a document that is not made of sections alone', () => {
|
|
87
|
+
const doc = { id: 'd2', type: 'ui_direction', data: { picks: { palette: 'ocean' } } };
|
|
88
|
+
|
|
89
|
+
expect(stripProvenance(doc)).toEqual(doc);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/tools/sections.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
/** A section as stored: an object with `content` and metadata, or (older rows) a bare string. */
|
|
15
15
|
export type Sections = Record<string, unknown>;
|
|
16
16
|
|
|
17
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
18
18
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -36,3 +36,25 @@ export function applySectionEdit(sections: Sections, sectionId: string, content:
|
|
|
36
36
|
const prior = sections[sectionId];
|
|
37
37
|
return { ...sections, [sectionId]: isRecord(prior) ? { ...prior, content } : { content } };
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A document for reading, without its provenance: the top-level `derivedFrom`
|
|
42
|
+
* tree and `outdated` source list (PRO-2359), and each section's own
|
|
43
|
+
* `derivedFrom` (PRO-2158). They say which documents shaped this one, which
|
|
44
|
+
* the editor needs and a reader does not; `source` and `priority` stay, the
|
|
45
|
+
* server instructions explain them.
|
|
46
|
+
*/
|
|
47
|
+
export function stripProvenance(doc: Record<string, unknown>): Record<string, unknown> {
|
|
48
|
+
const { derivedFrom: _derivedFrom, outdated: _outdated, ...rest } = doc;
|
|
49
|
+
if (!isRecord(rest.data)) return rest;
|
|
50
|
+
const sections = sectionsOf(rest.data);
|
|
51
|
+
if (!sections) return rest;
|
|
52
|
+
const stripped = Object.fromEntries(
|
|
53
|
+
Object.entries(sections).map(([id, section]) => {
|
|
54
|
+
if (!isRecord(section)) return [id, section];
|
|
55
|
+
const { derivedFrom: _sectionDerivedFrom, ...keep } = section;
|
|
56
|
+
return [id, keep];
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
return { ...rest, data: { ...rest.data, sections: stripped } };
|
|
60
|
+
}
|
package/src/tools/summarize.ts
CHANGED
|
@@ -1,38 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Get endpoints return the full content.
|
|
6
|
-
*
|
|
7
|
-
* This keeps list calls cheap (~100 tokens per item) and lets the AI
|
|
8
|
-
* selectively load full content only for the items it needs.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/** Fields to strip from list responses -- these contain the bulk content. */
|
|
12
|
-
const HEAVY_FIELDS = ['content', 'data', 'sections'];
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Remove heavy content fields from each item in an array.
|
|
16
|
-
* Preserves all metadata (id, name, description, type, dates, etc).
|
|
17
|
-
*/
|
|
18
|
-
export function summarizeList<T extends Record<string, unknown>>(
|
|
19
|
-
items: T[]
|
|
20
|
-
): Omit<T, 'content' | 'data' | 'sections'>[] {
|
|
21
|
-
return items.map((item) => {
|
|
22
|
-
const summary = { ...item };
|
|
23
|
-
for (const field of HEAVY_FIELDS) {
|
|
24
|
-
if (field in summary) {
|
|
25
|
-
delete summary[field];
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return summary;
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Keep only the named fields of each item. For lists whose rows carry prose
|
|
34
|
-
* (a backlog item's description, a run's metrics) that `summarizeList`'s
|
|
35
|
-
* fixed field names would not catch.
|
|
2
|
+
* Keep only the named fields of each item. List tools return summaries (the
|
|
3
|
+
* fields a model needs to pick an item) and get tools return everything;
|
|
4
|
+
* this is how a list drops the prose and the provenance trees.
|
|
36
5
|
*/
|
|
37
6
|
export function pickFields<T extends Record<string, unknown>>(
|
|
38
7
|
items: T[],
|
|
Binary file
|
package/src/tools/inputs.test.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { FACTORY_DOCUMENT_TYPES, PRODUCT_DOCUMENT_TYPES, TENANT_DOCUMENT_TYPES } from '@taiga/types';
|
|
3
|
-
import { documentType } from './inputs.js';
|
|
4
|
-
|
|
5
|
-
describe('documentType', () => {
|
|
6
|
-
it('accepts every document type the shared types package names', () => {
|
|
7
|
-
// The identifier shape is a wall against path-shaped input, not a copy of
|
|
8
|
-
// the enums; this is what keeps it from silently rejecting a real type.
|
|
9
|
-
for (const type of [...TENANT_DOCUMENT_TYPES, ...FACTORY_DOCUMENT_TYPES, ...PRODUCT_DOCUMENT_TYPES]) {
|
|
10
|
-
expect(documentType.safeParse(type).success, type).toBe(true);
|
|
11
|
-
}
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('rejects anything that could reach another route', () => {
|
|
15
|
-
for (const value of ['../x', 'a/b', '.', '..', '', 'policy security', 'Policy', 'a?x=1', 'a#b']) {
|
|
16
|
-
expect(documentType.safeParse(value).success, JSON.stringify(value)).toBe(false);
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
});
|
package/src/tools/inputs.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A document type as the API names one: policy_security, factory_instructions,
|
|
5
|
-
* specification. An identifier, never a path: the value is interpolated into
|
|
6
|
-
* the route, and a type that carried a slash would reach a different
|
|
7
|
-
* document's route than the tool's description promises. The API's own enum
|
|
8
|
-
* stays the source of truth for which identifiers exist (it answers 400 with
|
|
9
|
-
* the valid list); this only rules out what could not be a type at all.
|
|
10
|
-
*/
|
|
11
|
-
export const documentType = z
|
|
12
|
-
.string()
|
|
13
|
-
.regex(/^[a-z][a-z0-9_]{0,63}$/, 'A document type is a lowercase identifier such as policy_security');
|
|
Binary file
|