@orboto/mcp 0.202.0 → 0.204.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/agent-head.js +1 -1
- package/dist/tools/admin-writes.js +6 -1
- package/dist/tools/agent-message-work.js +5 -2
- package/dist/tools/agent-messages.js +1 -1
- package/dist/tools/agent-presence-schema.js +2 -0
- package/dist/tools/backup.js +18 -8
- package/dist/tools/bulk-create.js +9 -3
- package/dist/tools/check-similar.js +6 -5
- package/dist/tools/list-projects.js +19 -13
- package/dist/tools/list-projects.test.js +21 -0
- package/dist/tools/phase-b-tools.test.js +21 -0
- package/dist/tools/search.js +9 -6
- package/dist/tools/similar-projection.js +3 -1
- package/dist/tools/ticket-writes.js +21 -7
- package/dist/tools/ticket-writes.test.js +43 -3
- package/package.json +1 -1
package/dist/agent-head.js
CHANGED
|
@@ -9,7 +9,7 @@ export const AGENT_HEAD_HEADING = 'How to work in orboto';
|
|
|
9
9
|
export const AGENT_HEAD_LINES = [
|
|
10
10
|
'orboto is your memory and your work contract, not your context - what is not on the ticket or in a doc is gone after the next context switch.',
|
|
11
11
|
'A ticket is a handover to a stranger: goal, verified current state, target, acceptance criteria.',
|
|
12
|
-
'You are not alone - the lease and the claims say who works on what, your scope says what you answer for, the inbox is the channel: ack what you handled, dismiss the rest with a reason.',
|
|
12
|
+
'You are not alone - the lease and the claims say who works on what, your scope says what you answer for, the inbox is the channel: ack what you handled and that ack closes the request unless somebody claimed it, dismiss the rest with a reason, and take orboto_message_work only for work that outlives this turn.',
|
|
13
13
|
'You act with a person\'s rights and mandate: an assignment is an instruction, anything irreversible or outward-facing needs the operator\'s clear word.',
|
|
14
14
|
'Evidence over assertion - check code, docs and the live state, and write "not verified: X" instead of a guess.',
|
|
15
15
|
'Start in this order: whoami, session start with your scope, the project primer, then the inbox - the rules are a set, this is the sequence.',
|
|
@@ -40,7 +40,10 @@ export function makeListUsersHandler(client) {
|
|
|
40
40
|
tags.push('external');
|
|
41
41
|
if (!u.isActive)
|
|
42
42
|
tags.push('disabled');
|
|
43
|
-
|
|
43
|
+
if (u.provisionedByPlatform)
|
|
44
|
+
tags.push('provisioned by the platform');
|
|
45
|
+
const purpose = u.provisionedByPlatform && u.purpose ? ` - ${u.purpose}` : '';
|
|
46
|
+
return `- ${u.fullName} <${u.email}>${tags.length ? ` [${tags.join(', ')}]` : ''}${purpose}`;
|
|
44
47
|
}).join('\n');
|
|
45
48
|
return {
|
|
46
49
|
content: [{ type: 'text', text }],
|
|
@@ -54,6 +57,8 @@ export function makeListUsersHandler(client) {
|
|
|
54
57
|
isExternal: u.isExternal,
|
|
55
58
|
isBot: u.isBot,
|
|
56
59
|
lastSeenAt: u.lastSeenAt ?? null,
|
|
60
|
+
provisionedByPlatform: u.provisionedByPlatform ?? false,
|
|
61
|
+
purpose: u.purpose ?? null,
|
|
57
62
|
})),
|
|
58
63
|
},
|
|
59
64
|
};
|
|
@@ -2,11 +2,14 @@ import { z } from 'zod';
|
|
|
2
2
|
import { mcpInstanceToken } from './shared.js';
|
|
3
3
|
export const agentMessageWorkToolConfig = {
|
|
4
4
|
title: 'Inspect or explicitly handle message work',
|
|
5
|
-
description: 'List, inspect or mutate durable message work (mutation schema: orboto_api_search).
|
|
5
|
+
description: 'List, inspect or mutate durable message work (mutation schema: orboto_api_search). Only request/error mail carries work; an ack completes it unless somebody claimed it.',
|
|
6
6
|
inputSchema: z.object({ messageId: z.string().uuid().optional(), mutation: z.record(z.unknown()).optional(),
|
|
7
7
|
cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(25), openOnly: z.boolean().default(true) }).shape,
|
|
8
8
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
9
9
|
};
|
|
10
|
+
/** ORB-2190 - the resolved states, said once in the tool output rather than in the manifest. */
|
|
11
|
+
export const WORK_STATE_NOTE = 'carriesWork false = info/complete/digest mail, no work state. An open list holds requests and errors only, oldest first; '
|
|
12
|
+
+ 'presence unclaimed | active | stalled | expired, and completed once you finish it, an ack closes it, a dismissal closes it or its ticket resolves it.';
|
|
10
13
|
export function makeAgentMessageWorkHandler(client) {
|
|
11
14
|
return async (args, extra) => {
|
|
12
15
|
if (args.mutation && !args.messageId)
|
|
@@ -17,6 +20,6 @@ export function makeAgentMessageWorkHandler(client) {
|
|
|
17
20
|
const result = args.mutation
|
|
18
21
|
? await client.post(`/v1/agent/messages/${args.messageId}/work`, { ...args.mutation, instanceToken: mcpInstanceToken(undefined, extra) })
|
|
19
22
|
: await client.get(`${args.messageId ? `/v1/agent/messages/${args.messageId}` : '/v1/agent/message-work'}?${q}`, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
20
|
-
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
23
|
+
return { content: [{ type: 'text', text: `${JSON.stringify(result)}\n${WORK_STATE_NOTE}` }], structuredContent: result };
|
|
21
24
|
};
|
|
22
25
|
}
|
|
@@ -13,7 +13,7 @@ import { mcpInstanceToken } from './shared.js';
|
|
|
13
13
|
const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
|
|
14
14
|
export const agentMessagesToolConfig = {
|
|
15
15
|
title: 'Agent inbox and message work',
|
|
16
|
-
description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds
|
|
16
|
+
description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request, never claimed work. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
messageWork: z.record(z.unknown()).optional()
|
|
19
19
|
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
|
|
@@ -49,6 +49,8 @@ export const AgentInventoryEntrySchema = z.object({
|
|
|
49
49
|
restartPending: z.boolean(),
|
|
50
50
|
/** ORB-2181 - the reason the requester gave; null when nothing is pending. */
|
|
51
51
|
restartReason: z.string().nullable(),
|
|
52
|
+
/** ORB-2189 - the answering instance holds this session's live wake channel, so a restart request can reach it. */
|
|
53
|
+
restartable: z.boolean(),
|
|
52
54
|
lastSeenAt: z.string(),
|
|
53
55
|
createdAt: z.string(),
|
|
54
56
|
});
|
package/dist/tools/backup.js
CHANGED
|
@@ -8,16 +8,23 @@
|
|
|
8
8
|
* download mechanism. `OrbotoClient.postBinary` / `getBinary` fetch the bytes.
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
+
/**
|
|
12
|
+
* ORB-1504 - an operator passphrase is deliberately NOT an MCP input: it
|
|
13
|
+
* would land in the conversation transcript and the tool-call audit. The
|
|
14
|
+
* passphrase paths are REST and `orboto backup --passphrase-stdin`; an agent
|
|
15
|
+
* that must drive one uses the escape hatch `orboto_api_call`.
|
|
16
|
+
*/
|
|
17
|
+
const seal = (run) => run.sealed ? ` [encrypted, ${run.sealCustody ?? 'platform'} key${run.sealKeyFingerprint ? ` ${run.sealKeyFingerprint}` : ''}]` : '';
|
|
11
18
|
const mb = (n) => `${Math.round((n / 1024 / 1024) * 10) / 10} MB`;
|
|
12
19
|
export const createFullBackupToolConfig = {
|
|
13
20
|
title: 'Create + download a full workspace backup',
|
|
14
|
-
description: 'Create an on-demand FULL workspace backup (database + storage) and return
|
|
21
|
+
description: 'Create an on-demand FULL workspace backup (database + storage) and return it as a base64 resource attachment - one call; a background run this tool polls, minutes on a large workspace. Rows and file references share one snapshot; a missing file fails the run, never a partial archive. With backup encryption on the attachment is the sealed archive, not a ZIP. Requires admin:backup:export; scheduled jobs use orboto_trigger_backup.',
|
|
15
22
|
inputSchema: z.object({}).shape,
|
|
16
23
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
17
24
|
};
|
|
18
25
|
export function makeCreateFullBackupHandler(client) {
|
|
19
26
|
return async () => {
|
|
20
|
-
const { runId } = await client.post('/admin/backup/full', {});
|
|
27
|
+
const { runId, sealed } = await client.post('/admin/backup/full', {});
|
|
21
28
|
const t0 = Date.now();
|
|
22
29
|
for (;;) {
|
|
23
30
|
await new Promise((r) => setTimeout(r, 3000));
|
|
@@ -31,12 +38,15 @@ export function makeCreateFullBackupHandler(client) {
|
|
|
31
38
|
}
|
|
32
39
|
const { bytes, contentType } = await client.getBinary(`/admin/backup/runs/${runId}/download`);
|
|
33
40
|
const base64 = Buffer.from(bytes).toString('base64');
|
|
41
|
+
const note = sealed
|
|
42
|
+
? ' The archive is encrypted with the instance key - it only restores where that key exists.'
|
|
43
|
+
: '';
|
|
34
44
|
return {
|
|
35
45
|
content: [
|
|
36
|
-
{ type: 'resource', resource: { uri: 'orboto://backup/full.zip', mimeType: contentType, blob: base64 } },
|
|
37
|
-
{ type: 'text', text: `Created full workspace backup (${mb(bytes.byteLength)}), run ${runId}
|
|
46
|
+
{ type: 'resource', resource: { uri: sealed ? 'orboto://backup/full.zip.orbenc' : 'orboto://backup/full.zip', mimeType: contentType, blob: base64 } },
|
|
47
|
+
{ type: 'text', text: `Created full workspace backup (${mb(bytes.byteLength)}), run ${runId}.${note}` },
|
|
38
48
|
],
|
|
39
|
-
structuredContent: { sizeBytes: bytes.byteLength, contentType, runId },
|
|
49
|
+
structuredContent: { sizeBytes: bytes.byteLength, contentType, runId, sealed },
|
|
40
50
|
};
|
|
41
51
|
};
|
|
42
52
|
}
|
|
@@ -50,7 +60,7 @@ export function makeListBackupsHandler(client) {
|
|
|
50
60
|
return async () => {
|
|
51
61
|
const page = await client.get('/admin/backup/runs');
|
|
52
62
|
const items = page.items ?? [];
|
|
53
|
-
const lines = items.map((r) => `- ${r.id} - ${r.status}${r.fileSizeBytes ? ` (${mb(r.fileSizeBytes)})` : ''} - ${r.completedAt ?? r.startedAt}`);
|
|
63
|
+
const lines = items.map((r) => `- ${r.id} - ${r.status}${r.fileSizeBytes ? ` (${mb(r.fileSizeBytes)})` : ''} - ${r.completedAt ?? r.startedAt}${seal(r)}`);
|
|
54
64
|
return {
|
|
55
65
|
content: [{ type: 'text', text: items.length ? `Backup runs:\n${lines.join('\n')}` : 'No backup runs yet.' }],
|
|
56
66
|
structuredContent: { runs: items },
|
|
@@ -59,7 +69,7 @@ export function makeListBackupsHandler(client) {
|
|
|
59
69
|
}
|
|
60
70
|
export const downloadBackupToolConfig = {
|
|
61
71
|
title: 'Download a stored backup run',
|
|
62
|
-
description: 'Download a stored backup run
|
|
72
|
+
description: 'Download a stored backup run by run id (from orboto_list_backups) as a base64 resource attachment; an encrypted run downloads sealed and needs its key to restore. Requires admin:backup:read.',
|
|
63
73
|
inputSchema: z.object({
|
|
64
74
|
runId: z.string().uuid().describe('Backup run id from orboto_list_backups.'),
|
|
65
75
|
}).shape,
|
|
@@ -71,7 +81,7 @@ export function makeDownloadBackupHandler(client) {
|
|
|
71
81
|
const base64 = Buffer.from(bytes).toString('base64');
|
|
72
82
|
return {
|
|
73
83
|
content: [
|
|
74
|
-
{ type: 'resource', resource: { uri: `orboto://backup/run-${runId}.zip`, mimeType: contentType, blob: base64 } },
|
|
84
|
+
{ type: 'resource', resource: { uri: `orboto://backup/run-${runId}${contentType.includes('sealed') ? '.zip.orbenc' : '.zip'}`, mimeType: contentType, blob: base64 } },
|
|
75
85
|
{ type: 'text', text: `Downloaded backup run ${runId.slice(0, 8)} (${mb(bytes.byteLength)}).` },
|
|
76
86
|
],
|
|
77
87
|
structuredContent: { runId, sizeBytes: bytes.byteLength, contentType },
|
|
@@ -35,7 +35,7 @@ export const bulkCreateTicketsToolConfig = {
|
|
|
35
35
|
failed: z.array(z.object({ index: z.number().int(), title: z.string(), error: z.string() })),
|
|
36
36
|
duplicateFlags: z.array(z.object({
|
|
37
37
|
key: z.string().describe('The new, flagged ticket.'),
|
|
38
|
-
matches: z.array(z.object({ ticketKey: z.string().nullable(), similarity: z.number() })),
|
|
38
|
+
matches: z.array(z.object({ ticketKey: z.string().nullable(), similarity: z.number().nullable() })),
|
|
39
39
|
})),
|
|
40
40
|
languageWarnings: z.number().int().describe('Drafts with a language warning.'),
|
|
41
41
|
}).shape,
|
|
@@ -65,6 +65,7 @@ export function makeBulkCreateTicketsHandler(client) {
|
|
|
65
65
|
const created = [];
|
|
66
66
|
const failed = [];
|
|
67
67
|
const duplicateFlags = [];
|
|
68
|
+
let degraded = 0;
|
|
68
69
|
let languageWarnings = 0;
|
|
69
70
|
for (let i = 0; i < input.tickets.length; i++) {
|
|
70
71
|
const draft = input.tickets[i];
|
|
@@ -91,13 +92,15 @@ export function makeBulkCreateTicketsHandler(client) {
|
|
|
91
92
|
created.push(res.ticketKey ?? res.id);
|
|
92
93
|
if (res.languageWarning)
|
|
93
94
|
languageWarnings++;
|
|
95
|
+
if (res.duplicateCheckDegraded)
|
|
96
|
+
degraded++;
|
|
94
97
|
const warnings = res.similarWarnings ?? [];
|
|
95
98
|
if (warnings.length > 0) {
|
|
96
99
|
duplicateFlags.push({
|
|
97
100
|
key: res.ticketKey ?? res.id,
|
|
98
101
|
matches: warnings.slice(0, 3).map((w) => ({
|
|
99
102
|
ticketKey: w.ticketKey,
|
|
100
|
-
similarity: Math.round(w.similarity * 100) / 100,
|
|
103
|
+
similarity: w.matchMode === 'embedding' ? Math.round(w.similarity * 100) / 100 : null,
|
|
101
104
|
})),
|
|
102
105
|
});
|
|
103
106
|
}
|
|
@@ -115,7 +118,7 @@ export function makeBulkCreateTicketsHandler(client) {
|
|
|
115
118
|
if (created.length > 0)
|
|
116
119
|
lines.push(`Created: ${created.join(', ')}`);
|
|
117
120
|
for (const f of duplicateFlags) {
|
|
118
|
-
lines.push(`⚠ ${f.key} may duplicate ${f.matches.map((m) => `${m.ticketKey ?? '?'} (${m.similarity})`).join(', ')} - review before treating as new work.`);
|
|
121
|
+
lines.push(`⚠ ${f.key} may duplicate ${f.matches.map((m) => `${m.ticketKey ?? '?'} (${m.similarity === null ? 'text match' : m.similarity})`).join(', ')} - review before treating as new work.`);
|
|
119
122
|
}
|
|
120
123
|
for (const f of failed) {
|
|
121
124
|
lines.push(`✗ draft ${f.index} "${f.title}": ${f.error}`);
|
|
@@ -123,6 +126,9 @@ export function makeBulkCreateTicketsHandler(client) {
|
|
|
123
126
|
if (languageWarnings > 0) {
|
|
124
127
|
lines.push(`⚠ ${languageWarnings} draft(s) in a non-workspace language - consider rewriting for search/duplicate consistency.`);
|
|
125
128
|
}
|
|
129
|
+
if (degraded > 0) {
|
|
130
|
+
lines.push(`ℹ ${degraded} draft(s) ran a degraded duplicate-check - the block is on but only full-text candidates were found, whose rank is relative, so nothing was compared with the threshold.`);
|
|
131
|
+
}
|
|
126
132
|
return {
|
|
127
133
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
128
134
|
structuredContent: { created, failed, duplicateFlags, languageWarnings },
|
|
@@ -6,7 +6,7 @@ import { trimSimilarEntries } from './similar-projection.js';
|
|
|
6
6
|
import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
|
|
7
7
|
export const checkSimilarToolConfig = {
|
|
8
8
|
title: 'Check for similar tickets before creating',
|
|
9
|
-
description: 'Run the duplicate-detection pipeline (tsvector + AI-embedding rerank when configured) against a proposed title + description, without creating anything. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
|
|
9
|
+
description: 'Run the duplicate-detection pipeline (tsvector + AI-embedding rerank when configured) against a proposed title + description, without creating anything. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. Only an AI-embedding candidate carries an absolute similarity; a full-text candidate prints as "text match" without a percentage (its rank is relative, so the best text match always scores 1.000) and can never trigger the hard duplicate-block, and neither can a match against a CLOSED ticket (ORB-2185). An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
|
|
10
10
|
inputSchema: z.object({
|
|
11
11
|
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
12
12
|
title: z.string().min(1).describe('Proposed ticket title.'),
|
|
@@ -35,12 +35,13 @@ export function makeCheckSimilarHandler(client) {
|
|
|
35
35
|
const result = await client.get(`/projects/${project.id}/tickets/similar?${qs.toString()}`);
|
|
36
36
|
const realDuplicates = result.candidates.filter((c) => !c.relation);
|
|
37
37
|
const related = result.candidates.filter((c) => c.relation);
|
|
38
|
+
const strong = realDuplicates.find((c) => c.matchMode === 'embedding' && c.similarity >= 0.9);
|
|
38
39
|
const recommendation = realDuplicates.length === 0
|
|
39
40
|
? (related.length === 0
|
|
40
41
|
? 'No similar tickets found - safe to create.'
|
|
41
42
|
: 'Only related context found (parent/sibling/epic) - safe to create; link them instead of treating as duplicates.')
|
|
42
|
-
:
|
|
43
|
-
? `HIGH-SIMILARITY MATCH FOUND - review [${
|
|
43
|
+
: strong
|
|
44
|
+
? `HIGH-SIMILARITY MATCH FOUND - review [${strong.ticketKey ?? strong.id.slice(0, 8)}] "${strong.title}" before creating; this may already be tracked.`
|
|
44
45
|
: 'Possible related tickets - review the list and decide whether the new ticket adds distinct scope.';
|
|
45
46
|
const text = result.candidates.length === 0
|
|
46
47
|
? `${recommendation} (match mode: ${result.mode})`
|
|
@@ -48,11 +49,11 @@ export function makeCheckSimilarHandler(client) {
|
|
|
48
49
|
recommendation,
|
|
49
50
|
`Found ${result.candidates.length} candidate(s) via ${result.mode === 'embedding' ? 'AI embedding rerank' : 'tsvector search'}:`,
|
|
50
51
|
...result.candidates.map((c) => {
|
|
51
|
-
const
|
|
52
|
+
const score = c.matchMode === 'embedding' ? `${Math.round(c.similarity * 100)}% AI match` : 'text match';
|
|
52
53
|
const status = c.statusName ? ` [${c.statusName}]` : '';
|
|
53
54
|
const key = c.ticketKey ?? c.id.slice(0, 8);
|
|
54
55
|
const rel = c.relation ? ` (related: ${c.relation})` : '';
|
|
55
|
-
return ` - ${key}${status} (${
|
|
56
|
+
return ` - ${key}${status} (${score})${rel}: ${c.title}`;
|
|
56
57
|
}),
|
|
57
58
|
].join('\n');
|
|
58
59
|
return {
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export const listProjectsToolConfig = {
|
|
6
6
|
title: 'List projects',
|
|
7
|
-
description: '
|
|
8
|
-
+ '
|
|
9
|
-
+ '
|
|
10
|
-
+ 'When the result says it is partial, refine with `query` rather than re-calling.',
|
|
7
|
+
description: 'Projects the user can see. To find one, pass `search` (key, name, description, customer) '
|
|
8
|
+
+ 'instead of pulling the whole list; a project key usually works directly with other tools. '
|
|
9
|
+
+ 'When the result is partial, refine rather than re-call.',
|
|
11
10
|
inputSchema: z.object({
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
search: z.string().optional().describe('Key, name, description, customer.'),
|
|
12
|
+
query: z.string().optional().describe('Key/name substring.'),
|
|
13
|
+
limit: z.number().int().min(1).max(200).optional().describe('Default 50.'),
|
|
14
14
|
}).shape,
|
|
15
15
|
outputSchema: z.object({
|
|
16
16
|
projects: z.array(z.object({
|
|
@@ -27,22 +27,28 @@ export const listProjectsToolConfig = {
|
|
|
27
27
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
28
28
|
};
|
|
29
29
|
export function makeListProjectsHandler(client) {
|
|
30
|
-
return async ({ query, limit } = {}) => {
|
|
31
|
-
const
|
|
30
|
+
return async ({ search, query, limit } = {}) => {
|
|
31
|
+
const s = (search ?? '').trim();
|
|
32
|
+
const [projects, found] = await Promise.all([
|
|
33
|
+
client.get('/projects'),
|
|
34
|
+
s ? client.get(`/projects?search=${encodeURIComponent(s)}`) : Promise.resolve(null),
|
|
35
|
+
]);
|
|
32
36
|
const q = (query ?? '').trim().toLowerCase();
|
|
37
|
+
const base = found ?? projects;
|
|
33
38
|
const matched = q
|
|
34
|
-
?
|
|
35
|
-
:
|
|
39
|
+
? base.filter((p) => `${p.key} ${p.name}`.toLowerCase().includes(q))
|
|
40
|
+
: base;
|
|
41
|
+
const term = [s, q].filter(Boolean).map((v) => `"${v}"`).join(' and ');
|
|
36
42
|
const cap = Math.min(limit ?? 50, 200);
|
|
37
43
|
const shown = matched.slice(0, cap);
|
|
38
44
|
const rows = shown.map((p) => ({ id: p.id, key: p.key, name: p.name, status: p.status, description: p.description }));
|
|
39
45
|
const lines = rows.map((r) => `- ${r.key} - ${r.name} (${r.status})`);
|
|
40
46
|
const partial = shown.length < matched.length;
|
|
41
47
|
const footer = matched.length === 0
|
|
42
|
-
? (
|
|
48
|
+
? (term ? `No projects match ${term}.` : 'No projects visible to this user.')
|
|
43
49
|
: partial
|
|
44
|
-
? `\nShowing first ${shown.length} of ${matched.length} match(es) (of ${projects.length} total) - pass a narrower
|
|
45
|
-
: `\n(${matched.length} project(s)${
|
|
50
|
+
? `\nShowing first ${shown.length} of ${matched.length} match(es) (of ${projects.length} total) - pass a narrower search to filter.`
|
|
51
|
+
: `\n(${matched.length} project(s)${term ? ` matching ${term}` : ''}, complete.)`;
|
|
46
52
|
const text = matched.length === 0 ? footer : lines.join('\n') + footer;
|
|
47
53
|
return {
|
|
48
54
|
content: [{ type: 'text', text }],
|
|
@@ -57,6 +57,27 @@ describe('tools/list-projects', () => {
|
|
|
57
57
|
expect(text).toContain('Showing first 2 of 5 match(es)');
|
|
58
58
|
expect(result.structuredContent).toMatchObject({ total: 5, totalProjects: 5 });
|
|
59
59
|
});
|
|
60
|
+
it('ORB-2203: search asks the API with ?search= and still counts every visible project', async () => {
|
|
61
|
+
const all = [
|
|
62
|
+
{ id: 'p1', key: 'ACME', name: 'Acme', description: 'Customer portal', status: 'active' },
|
|
63
|
+
{ id: 'p2', key: 'TOOL', name: 'Internal Tools', description: null, status: 'draft' },
|
|
64
|
+
];
|
|
65
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
66
|
+
const url = String(input);
|
|
67
|
+
const body = url.includes('search=') ? [all[0]] : all;
|
|
68
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body };
|
|
69
|
+
});
|
|
70
|
+
const result = await makeListProjectsHandler(client)({ search: ' portal ' });
|
|
71
|
+
const urls = fetchSpy.mock.calls.map(([input]) => String(input));
|
|
72
|
+
expect(urls.some((u) => u.endsWith('/projects?search=portal'))).toBe(true);
|
|
73
|
+
expect(result.content[0]).toEqual({ type: 'text', text: '- ACME - Acme (active)\n(1 project(s) matching "portal", complete.)' });
|
|
74
|
+
expect(result.structuredContent).toMatchObject({ total: 1, totalProjects: 2, query: null });
|
|
75
|
+
});
|
|
76
|
+
it('ORB-2203: without search only the plain list is fetched', async () => {
|
|
77
|
+
const fetchSpy = mockFetch([]);
|
|
78
|
+
await makeListProjectsHandler(client)({ query: 'x' });
|
|
79
|
+
expect(fetchSpy.mock.calls.map(([input]) => String(input)).every((u) => !u.includes('search='))).toBe(true);
|
|
80
|
+
});
|
|
60
81
|
it('renders an empty-state text block when the API returns no rows', async () => {
|
|
61
82
|
mockFetch([]);
|
|
62
83
|
const result = await makeListProjectsHandler(client)();
|
|
@@ -490,6 +490,27 @@ describe('orboto_search', () => {
|
|
|
490
490
|
expect(sc.total).toBe(42);
|
|
491
491
|
expect(sc.hasMore).toBe(true);
|
|
492
492
|
});
|
|
493
|
+
it('ORB-2201: passes the jump types and renders project and milestone hits by key', async () => {
|
|
494
|
+
const calls = stub([
|
|
495
|
+
{
|
|
496
|
+
json: {
|
|
497
|
+
items: [
|
|
498
|
+
{ type: 'project', id: 'p1', key: 'ACME', projectKey: 'ACME', title: 'Acme', excerpt: '', projectId: 'p1', projectName: 'Acme', spaceId: null, spaceName: null, url: '/projects/acme', rank: 2000 },
|
|
499
|
+
{ type: 'milestone', id: 'm1', key: 'ACME-M1', projectKey: 'ACME', title: 'Launch', excerpt: '', projectId: 'p1', projectName: 'Acme', spaceId: null, spaceName: null, url: '/projects/acme?milestoneIds=m1', rank: 200 },
|
|
500
|
+
],
|
|
501
|
+
nextCursor: null,
|
|
502
|
+
total: 2,
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
]);
|
|
506
|
+
const res = await makeSearchHandler(client)({ query: 'acme', types: ['project', 'milestone'] });
|
|
507
|
+
expect(calls[0]).toContain('types=project%2Cmilestone');
|
|
508
|
+
const text = res.content[0].text;
|
|
509
|
+
expect(text).toContain('- [PROJECT ACME] Acme');
|
|
510
|
+
expect(text).toContain('- [MILESTONE ACME-M1 · Acme] Launch');
|
|
511
|
+
const sc = res.structuredContent;
|
|
512
|
+
expect(sc.hits.map((h) => [h.key, h.projectKey])).toEqual([['ACME', 'ACME'], ['ACME-M1', 'ACME']]);
|
|
513
|
+
});
|
|
493
514
|
});
|
|
494
515
|
describe('doc tools', () => {
|
|
495
516
|
it('list: uses DocSpace.type to label scope, not a joined projectName', async () => {
|
package/dist/tools/search.js
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { resolveProjectByKey } from './shared.js';
|
|
8
|
+
const SEARCH_TYPES = ['ticket', 'comment', 'doc', 'project', 'milestone', 'space', 'customer'];
|
|
8
9
|
export const searchToolConfig = {
|
|
9
10
|
title: 'Search across orboto',
|
|
10
|
-
description: '
|
|
11
|
+
description: 'Search tickets, comments and docs; `types` adds projects, milestones, spaces, customers. Content matches full text, jump types match key and name; `types` defaults to ticket, comment, doc. Honours the caller\'s visibility - private tickets and internal comments never appear unless the caller can already see them. Recall (ORB-1695): when the strict all-terms pass finds nothing, the server automatically retries with an OR-relaxed pass plus semantic (embedding) recall - the response\'s `pass` field says which pass produced the hits, and relaxed-pass hits deserve a skeptical read (they matched SOME terms or only the meaning, not all terms). Still prefer a single distinctive STABLE token (file/component/error-string fragment like "AdminCodesPage") and search the SYMPTOM, not your intended fix.',
|
|
11
12
|
inputSchema: z.object({
|
|
12
13
|
query: z.string().min(1).describe('Search terms, e.g. "queue worker retry".'),
|
|
13
|
-
types: z.array(z.enum(
|
|
14
|
-
.describe('Restrict to entity types. Omit for all.'),
|
|
14
|
+
types: z.array(z.enum(SEARCH_TYPES)).optional(),
|
|
15
15
|
projectKey: z.string().optional().describe('Restrict to one project.'),
|
|
16
16
|
limit: z.number().int().min(1).max(50).default(15),
|
|
17
17
|
}).shape,
|
|
@@ -39,9 +39,10 @@ export function makeSearchHandler(client) {
|
|
|
39
39
|
? `No hits for "${input.query}".`
|
|
40
40
|
: `Hits${headerHint}${relaxedNote}:\n\n` + res.items.map((h) => {
|
|
41
41
|
const tag = h.type.toUpperCase();
|
|
42
|
-
const ident = h.ticketKey ?? h.id;
|
|
43
|
-
const project = h.projectName ? ` · ${h.projectName}` : '';
|
|
44
|
-
|
|
42
|
+
const ident = h.ticketKey ?? h.key ?? h.id;
|
|
43
|
+
const project = h.projectName && h.type !== 'project' ? ` · ${h.projectName}` : '';
|
|
44
|
+
const excerpt = h.excerpt ? `\n ${h.excerpt}` : '';
|
|
45
|
+
return `- [${tag} ${ident}${project}] ${h.title}${excerpt}`;
|
|
45
46
|
}).join('\n\n');
|
|
46
47
|
return {
|
|
47
48
|
content: [{ type: 'text', text }],
|
|
@@ -56,6 +57,8 @@ export function makeSearchHandler(client) {
|
|
|
56
57
|
title: h.title,
|
|
57
58
|
excerpt: h.excerpt,
|
|
58
59
|
ticketKey: h.ticketKey ?? null,
|
|
60
|
+
key: h.key ?? h.ticketKey ?? null,
|
|
61
|
+
projectKey: h.projectKey ?? null,
|
|
59
62
|
projectName: h.projectName,
|
|
60
63
|
spaceName: h.spaceName,
|
|
61
64
|
url: h.url,
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* ORB-1693 - the agent-facing projection of a duplicate-warning entry.
|
|
3
3
|
*/
|
|
4
4
|
export function trimSimilarEntry(w) {
|
|
5
|
+
const matchMode = w.matchMode ?? 'embedding';
|
|
5
6
|
return {
|
|
6
7
|
ticketKey: w.ticketKey ?? w.id.slice(0, 8),
|
|
7
8
|
title: w.title,
|
|
8
9
|
statusCategory: w.statusCategory ?? null,
|
|
9
|
-
similarity: Math.round(w.similarity * 100) / 100,
|
|
10
|
+
similarity: matchMode === 'embedding' ? Math.round(w.similarity * 100) / 100 : null,
|
|
11
|
+
matchMode,
|
|
10
12
|
relation: w.relation ?? null,
|
|
11
13
|
};
|
|
12
14
|
}
|
|
@@ -53,7 +53,7 @@ function ticketStructured(t) {
|
|
|
53
53
|
}
|
|
54
54
|
export const createTicketToolConfig = {
|
|
55
55
|
title: 'Create a ticket',
|
|
56
|
-
description: 'Create a new ticket in the given project. Creating more than ~3 tickets? Use `orboto_bulk_create_tickets` instead - one call, one compact response. Returns the new ticket\'s key (e.g. "ACME-42") so callers can chain follow-ups. **Read the new key from `structuredContent.createdTicketKey` (ORB-1176) - never from `similarWarnings[].ticketKey`, which are OTHER, possibly-duplicate tickets.** The caller must have `ticket:create` on the project. **Duplicate-detection safety-net (ORB-831):** if `similarWarnings` appears in the response with one or more entries, the ticket WAS created but you should review whether to close it as a duplicate of the listed ticket(s) instead. The warnings are advisory - never blocking - but each entry is a ticket the system thinks the new one overlaps with. Prefer `orboto_check_similar` BEFORE creating when you want a dry-run. **Deferred check under load (ORB-1437):** if `duplicateCheckDeferred: true` appears, the project was under a create burst so the duplicate-check was run in the background instead of inline - `similarWarnings` is then empty because it did NOT run synchronously, which is NOT the same as "no duplicates found". A strong match, if any, is posted as an advisory comment on the new ticket a moment later; check the ticket comments before treating it as new work. **Duplicate-check recall (ORB-1121):** when you search/check-similar first, results rank by term co-occurrence - a long, solution-framed title with rare terms can return 0 hits even when a short, symptom-framed dup sharing one distinctive token exists. Probe with a single distinctive STABLE token (file/component/error-string fragment), keep queries SHORT, and search the SYMPTOM not your fix; a 0-result long query is not "no dup". **Language-mismatch warning (ORB-890):** if `languageWarning` appears, the ticket was written in a language different from the workspace default. Consider rewriting in the expected language so search + duplicate-detection stay consistent. Non-blocking. **Before a mass-create (ORB-989):** call `orboto_whoami` first - its `workspaceLocale` field is the language you should write every ticket in. If the same `languageWarning` repeats, stop and clarify the intended language rather than pushing through the whole batch. **Strict mode (ORB-990):** if the workspace enforces ticket language, a mismatch is rejected (the tool returns a `blocked` result, not a created ticket) - rewrite in the workspace language, or set `allowLanguageMismatch: true` only when the language is genuinely intentional. **Hard duplicate-block (ORB-1471):**
|
|
56
|
+
description: 'Create a new ticket in the given project. Creating more than ~3 tickets? Use `orboto_bulk_create_tickets` instead - one call, one compact response. Returns the new ticket\'s key (e.g. "ACME-42") so callers can chain follow-ups. **Read the new key from `structuredContent.createdTicketKey` (ORB-1176) - never from `similarWarnings[].ticketKey`, which are OTHER, possibly-duplicate tickets.** The caller must have `ticket:create` on the project. **Duplicate-detection safety-net (ORB-831):** if `similarWarnings` appears in the response with one or more entries, the ticket WAS created but you should review whether to close it as a duplicate of the listed ticket(s) instead. The warnings are advisory - never blocking - but each entry is a ticket the system thinks the new one overlaps with. Prefer `orboto_check_similar` BEFORE creating when you want a dry-run. **Deferred check under load (ORB-1437):** if `duplicateCheckDeferred: true` appears, the project was under a create burst so the duplicate-check was run in the background instead of inline - `similarWarnings` is then empty because it did NOT run synchronously, which is NOT the same as "no duplicates found". A strong match, if any, is posted as an advisory comment on the new ticket a moment later; check the ticket comments before treating it as new work. **Duplicate-check recall (ORB-1121):** when you search/check-similar first, results rank by term co-occurrence - a long, solution-framed title with rare terms can return 0 hits even when a short, symptom-framed dup sharing one distinctive token exists. Probe with a single distinctive STABLE token (file/component/error-string fragment), keep queries SHORT, and search the SYMPTOM not your fix; a 0-result long query is not "no dup". **Language-mismatch warning (ORB-890):** if `languageWarning` appears, the ticket was written in a language different from the workspace default. Consider rewriting in the expected language so search + duplicate-detection stay consistent. Non-blocking. **Before a mass-create (ORB-989):** call `orboto_whoami` first - its `workspaceLocale` field is the language you should write every ticket in. If the same `languageWarning` repeats, stop and clarify the intended language rather than pushing through the whole batch. **Strict mode (ORB-990):** if the workspace enforces ticket language, a mismatch is rejected (the tool returns a `blocked` result, not a created ticket) - rewrite in the workspace language, or set `allowLanguageMismatch: true` only when the language is genuinely intentional. **Hard duplicate-block (ORB-1471, ORB-2185):** a workspace can REFUSE a create - the tool returns `duplicateBlocked` (no ticket) with the candidates. ONLY an AI-embedding match at/above the threshold against an OPEN ticket blocks; a text match and a closed match stay warnings, and a text match is printed WITHOUT a percentage because a text rank is relative, not a similarity. Extend one of the candidates, or retry with `allowDuplicate: true` AND a `duplicateJustification` (saved as a comment). `duplicateCheckDegraded: true` = the block was on but only text candidates existed, so nothing could be compared and the create went through unchecked. '
|
|
57
57
|
+ '**Pre-flight (ORB-2162):** `dryRun: true` runs the same language, type, label and duplicate checks the write runs and returns their verdicts WITHOUT creating anything - `structuredContent.preflight` carries `ok`, `severity`, `blockedBy` and one verdict per check, each with the exact message the real create would answer with. Use it when a create would otherwise cost a round trip to fix the payload afterwards; re-send the same arguments without `dryRun` once the verdicts are clean. '
|
|
58
58
|
+ '**Parameter notes.** `deliveryMode` (ORB-1608) is the role-aware commit policy that replaced the blanket one-commit-per-ticket rule: implementation/docs expect exactly one linked commit (closing without one is a non-blocking warning); review/admin/epic never expect one - reviews are read-only, admin work carries external evidence, epics derive completion from their children; unset defaults to "epic" when type=epic, else "implementation". `milestone` takes a key ("ORB-M3"), a name, or a UUID and is looked up in the project including closed milestones - unknown or ambiguous is an error, so pass the key/UUID when a name repeats. `labels` and `assigneeEmails` attach ATOMICALLY inside the create (ORB-1416): an unknown label or non-member email rolls the whole create back with a 400, leaving no orphan ticket - there is no separate attach call to retry.',
|
|
59
59
|
inputSchema: z.object({
|
|
@@ -142,6 +142,7 @@ export function makeCreateTicketHandler(client) {
|
|
|
142
142
|
const warnings = created.similarWarnings ?? [];
|
|
143
143
|
const langWarning = created.languageWarning;
|
|
144
144
|
const deferred = created.duplicateCheckDeferred === true;
|
|
145
|
+
const degraded = created.duplicateCheckDegraded === true;
|
|
145
146
|
const baseText = ticketSummaryText('Created', created);
|
|
146
147
|
const parts = [baseText];
|
|
147
148
|
if (deferred) {
|
|
@@ -149,6 +150,11 @@ export function makeCreateTicketHandler(client) {
|
|
|
149
150
|
+ `An empty duplicate list here does NOT mean "no duplicates". If a strong match is found, an advisory comment `
|
|
150
151
|
+ `will be posted on [${created.ticketKey}] shortly; review it before treating this as new work.`);
|
|
151
152
|
}
|
|
153
|
+
if (degraded) {
|
|
154
|
+
parts.push(`\nℹ Duplicate-check degraded - the workspace blocks duplicates, but this check found only full-text candidates. `
|
|
155
|
+
+ `A text rank is relative (the best match always scores 1.000), so nothing could be compared with the threshold and the create was allowed. `
|
|
156
|
+
+ `Read the candidates below yourself; if embeddings are missing workspace-wide, tell the operator (orboto_embedding_status).`);
|
|
157
|
+
}
|
|
152
158
|
if (warnings.length > 0) {
|
|
153
159
|
parts.push(`\n⚠ Potential duplicates found - review before treating this as new work:\n${warnings.map((w) => ` - [${w.ticketKey ?? w.id.slice(0, 8)}] "${w.title}" (${formatSimilarity(w)})`).join('\n')}\n If one of these covers the work, close [${created.ticketKey}] as a duplicate via orboto_close_ticket.`);
|
|
154
160
|
}
|
|
@@ -163,6 +169,7 @@ export function makeCreateTicketHandler(client) {
|
|
|
163
169
|
createdTicketKey: created.ticketKey,
|
|
164
170
|
similarWarnings: trimSimilarEntries(warnings),
|
|
165
171
|
...(deferred ? { duplicateCheckDeferred: true } : {}),
|
|
172
|
+
...(degraded ? { duplicateCheckDegraded: true } : {}),
|
|
166
173
|
...(langWarning ? { languageWarning: langWarning } : {}),
|
|
167
174
|
},
|
|
168
175
|
};
|
|
@@ -179,7 +186,7 @@ function preflightResult(preflight, projectKey) {
|
|
|
179
186
|
preflight.ok
|
|
180
187
|
? `✓ Pre-flight clean for ${projectKey} - re-send the same arguments without dryRun to create the ticket.`
|
|
181
188
|
: `⛔ Pre-flight BLOCKED for ${projectKey} (${preflight.blockedBy.join(', ')}) - nothing was created. Fix the payload and run the pre-flight again.`,
|
|
182
|
-
` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}`,
|
|
189
|
+
` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}${preflight.verdicts.language.hint ? ` Keep it only on purpose: ${preflight.verdicts.language.hint.query}.` : ''}`,
|
|
183
190
|
` ${icon[preflight.verdicts.type.severity]} type: ${preflight.verdicts.type.message}`,
|
|
184
191
|
` ${icon[preflight.verdicts.labels.severity]} labels: ${preflight.verdicts.labels.message}`,
|
|
185
192
|
` ${icon[preflight.verdicts.duplicates.severity]} duplicates: ${preflight.verdicts.duplicates.message}`,
|
|
@@ -250,12 +257,13 @@ function languageBlockResult(err, verb) {
|
|
|
250
257
|
if (!parsed.languageWarning)
|
|
251
258
|
return null;
|
|
252
259
|
const lw = parsed.languageWarning;
|
|
260
|
+
const retry = `retry the same call with ${parsed.hint?.query ?? 'allowLanguageMismatch=true'}`;
|
|
253
261
|
const text = `⛔ ${verb} blocked - strict ticket-language enforcement is on.\n` +
|
|
254
262
|
`This content reads as "${lw.detected}" but the workspace language is "${lw.expected}".\n` +
|
|
255
|
-
`Rewrite it in ${lw.expected.toUpperCase()}, or - only if you are sure the language is intentional - retry
|
|
263
|
+
`Rewrite it in ${lw.expected.toUpperCase()}, or - only if you are sure the language is intentional - ${retry}.`;
|
|
256
264
|
return {
|
|
257
265
|
content: [{ type: 'text', text }],
|
|
258
|
-
structuredContent: { blocked: true, languageWarning: lw },
|
|
266
|
+
structuredContent: { blocked: true, languageWarning: lw, ...(parsed.hint ? { hint: parsed.hint } : {}) },
|
|
259
267
|
isError: true,
|
|
260
268
|
};
|
|
261
269
|
}
|
|
@@ -278,7 +286,10 @@ function duplicateBlockResult(err) {
|
|
|
278
286
|
return null;
|
|
279
287
|
const candidates = parsed.similarWarnings;
|
|
280
288
|
const lines = candidates.map((w) => ` - [${w.ticketKey ?? w.id.slice(0, 8)}] "${w.title}" (${formatSimilarity(w)})`);
|
|
281
|
-
const
|
|
289
|
+
const decidedBy = parsed.decidingTicketKey
|
|
290
|
+
? `${parsed.decidingTicketKey}, AI embedding match`
|
|
291
|
+
: 'an AI embedding match';
|
|
292
|
+
const text = `⛔ Ticket create BLOCKED - this looks like a duplicate (top match ${Math.round((parsed.topSimilarity ?? 0) * 100)}%, block threshold ${Math.round((parsed.threshold ?? 0) * 100)}%, decided by ${decidedBy} on an OPEN ticket).\n` +
|
|
282
293
|
`Existing tickets it overlaps with:\n${lines.join('\n')}\n` +
|
|
283
294
|
`Extend or comment on one of these instead. If you have confirmed NONE of them cover this work, retry orboto_create_ticket with allowDuplicate: true AND a duplicateJustification explaining why it is not a duplicate (it is saved as a comment on the new ticket).`;
|
|
284
295
|
return {
|
|
@@ -287,14 +298,17 @@ function duplicateBlockResult(err) {
|
|
|
287
298
|
duplicateBlocked: true,
|
|
288
299
|
threshold: parsed.threshold ?? null,
|
|
289
300
|
topSimilarity: parsed.topSimilarity ?? null,
|
|
301
|
+
decidingMode: parsed.decidingMode ?? 'embedding',
|
|
302
|
+
decidingTicketKey: parsed.decidingTicketKey ?? null,
|
|
290
303
|
similarWarnings: trimSimilarEntries(candidates),
|
|
291
304
|
},
|
|
292
305
|
isError: true,
|
|
293
306
|
};
|
|
294
307
|
}
|
|
308
|
+
/** ORB-2185 - a full-text rank is relative, so a text match is never a percentage. */
|
|
295
309
|
function formatSimilarity(w) {
|
|
296
|
-
const
|
|
297
|
-
return w.statusName ? `${w.statusName}, ${
|
|
310
|
+
const score = w.matchMode === 'embedding' ? `${Math.round(w.similarity * 100)}% AI match` : 'text match';
|
|
311
|
+
return w.statusName ? `${w.statusName}, ${score}` : score;
|
|
298
312
|
}
|
|
299
313
|
export const updateTicketToolConfig = {
|
|
300
314
|
title: 'Update a ticket',
|
|
@@ -116,17 +116,33 @@ describe('orboto_create_ticket', () => {
|
|
|
116
116
|
expect(text).toContain('ACME-42');
|
|
117
117
|
expect(text).toContain('91% AI match');
|
|
118
118
|
expect(text).toContain('ACME-13');
|
|
119
|
+
expect(text).toContain('Done, text match');
|
|
120
|
+
expect(text).not.toContain('72%');
|
|
119
121
|
const sc = res.structuredContent;
|
|
120
122
|
expect(sc.similarWarnings).toHaveLength(2);
|
|
121
123
|
expect(sc.createdTicketKey).toBe('ACME-99');
|
|
122
124
|
expect(sc.similarWarnings.map((w) => w.ticketKey)).not.toContain(sc.createdTicketKey);
|
|
123
125
|
for (const w of sc.similarWarnings) {
|
|
124
|
-
expect(Object.keys(w).sort()).toEqual(['relation', 'similarity', 'statusCategory', 'ticketKey', 'title']);
|
|
126
|
+
expect(Object.keys(w).sort()).toEqual(['matchMode', 'relation', 'similarity', 'statusCategory', 'ticketKey', 'title']);
|
|
125
127
|
}
|
|
126
128
|
expect(sc.similarWarnings[0]).toEqual({
|
|
127
129
|
ticketKey: 'ACME-42', title: 'Authentication breaks for SAML',
|
|
128
|
-
statusCategory: 'in_progress', similarity: 0.91, relation: null,
|
|
130
|
+
statusCategory: 'in_progress', similarity: 0.91, matchMode: 'embedding', relation: null,
|
|
129
131
|
});
|
|
132
|
+
expect(sc.similarWarnings[1]).toEqual({
|
|
133
|
+
ticketKey: 'ACME-13', title: 'Auth flow regressed',
|
|
134
|
+
statusCategory: 'done', similarity: null, matchMode: 'tsvector', relation: null,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
it('a degraded duplicate-check is surfaced on the created ticket (ORB-2185)', async () => {
|
|
138
|
+
stub([
|
|
139
|
+
{ json: PROJ },
|
|
140
|
+
{ json: { ...TICKET, ticketKey: 'ACME-101', duplicateCheckDegraded: true, similarWarnings: [] } },
|
|
141
|
+
]);
|
|
142
|
+
const res = await makeCreateTicketHandler(client)({ projectKey: 'ACME', title: 'degraded check' });
|
|
143
|
+
const text = res.content[0].text;
|
|
144
|
+
expect(text).toContain('Duplicate-check degraded');
|
|
145
|
+
expect(res.structuredContent.duplicateCheckDegraded).toBe(true);
|
|
130
146
|
});
|
|
131
147
|
it('returns no warning block when similarWarnings is empty', async () => {
|
|
132
148
|
stub([
|
|
@@ -212,6 +228,25 @@ describe('orboto_create_ticket', () => {
|
|
|
212
228
|
expect(sc.blocked).toBe(true);
|
|
213
229
|
expect(sc.languageWarning.severity).toBe('block');
|
|
214
230
|
});
|
|
231
|
+
it('reads the way out from the hint, never from the sentence written for a person (ORB-2200)', async () => {
|
|
232
|
+
const blockBody = JSON.stringify({
|
|
233
|
+
error: 'This ticket looks like it is written in a different language (de) than this project expects (en). If the language is intentional, you can keep the ticket as it is.',
|
|
234
|
+
errorKey: 'errors.tickets.language_mismatch',
|
|
235
|
+
languageWarning: { code: 'language_mismatch', severity: 'block', detected: 'de', expected: 'en' },
|
|
236
|
+
hint: { query: 'allowLanguageMismatch=true' },
|
|
237
|
+
});
|
|
238
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
239
|
+
if ((init?.method ?? 'GET') === 'GET') {
|
|
240
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
|
|
241
|
+
}
|
|
242
|
+
return { ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody };
|
|
243
|
+
});
|
|
244
|
+
const res = await makeCreateTicketHandler(client)({ projectKey: 'ACME', title: 'Authentifizierung schlägt fehl bei externen Nutzern' });
|
|
245
|
+
const text = res.content[0].text;
|
|
246
|
+
expect(text).toContain('retry the same call with allowLanguageMismatch=true');
|
|
247
|
+
expect(text).not.toContain('keep the ticket as it is');
|
|
248
|
+
expect(res.structuredContent.hint).toEqual({ query: 'allowLanguageMismatch=true' });
|
|
249
|
+
});
|
|
215
250
|
it('passes allowLanguageMismatch as a query param when the override is set (ORB-990)', async () => {
|
|
216
251
|
let createUrl = '';
|
|
217
252
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
@@ -234,8 +269,10 @@ describe('orboto_create_ticket', () => {
|
|
|
234
269
|
errorKey: 'errors.tickets.duplicate_blocked',
|
|
235
270
|
threshold: 0.9,
|
|
236
271
|
topSimilarity: 0.98,
|
|
272
|
+
decidingMode: 'embedding',
|
|
273
|
+
decidingTicketKey: 'ACME-7',
|
|
237
274
|
similarWarnings: [
|
|
238
|
-
{ id: 't1', ticketKey: 'ACME-7', title: 'Webhook signature mismatch', statusName: 'To Do', statusColor: null, statusCategory: 'todo', similarity: 0.98, matchMode: '
|
|
275
|
+
{ id: 't1', ticketKey: 'ACME-7', title: 'Webhook signature mismatch', statusName: 'To Do', statusColor: null, statusCategory: 'todo', similarity: 0.98, matchMode: 'embedding' },
|
|
239
276
|
],
|
|
240
277
|
});
|
|
241
278
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
@@ -251,8 +288,11 @@ describe('orboto_create_ticket', () => {
|
|
|
251
288
|
expect(text).toContain('BLOCKED');
|
|
252
289
|
expect(text).toContain('ACME-7');
|
|
253
290
|
expect(text).toContain('allowDuplicate: true');
|
|
291
|
+
expect(text).toContain('decided by ACME-7, AI embedding match on an OPEN ticket');
|
|
254
292
|
const sc = res.structuredContent;
|
|
255
293
|
expect(sc.duplicateBlocked).toBe(true);
|
|
294
|
+
expect(sc.decidingMode).toBe('embedding');
|
|
295
|
+
expect(sc.decidingTicketKey).toBe('ACME-7');
|
|
256
296
|
expect(sc.similarWarnings).toHaveLength(1);
|
|
257
297
|
});
|
|
258
298
|
it('passes allowDuplicate as a query param and the justification in the body when overriding (ORB-1471)', async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.204.0",
|
|
4
4
|
"description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|