@orboto/mcp 0.221.0 → 0.221.11

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.
Files changed (38) hide show
  1. package/dist/event-bridge.js +2 -0
  2. package/dist/http-transport.js +28 -6
  3. package/dist/inbox-channel.js +14 -11
  4. package/dist/instructions-budget.test.js +72 -2
  5. package/dist/manifest-size.test.js +8 -0
  6. package/dist/orboto-client.js +3 -1
  7. package/dist/orboto-client.test.js +1 -1
  8. package/dist/server.js +18 -28
  9. package/dist/session-origin.js +100 -0
  10. package/dist/session-origin.test.js +127 -0
  11. package/dist/tools/agent-coordination.js +39 -11
  12. package/dist/tools/agent-coordination.test.js +53 -35
  13. package/dist/tools/agent-messages.js +16 -5
  14. package/dist/tools/agent-who.js +23 -8
  15. package/dist/tools/agent-who.test.js +12 -0
  16. package/dist/tools/check-similar.js +5 -3
  17. package/dist/tools/doc-edits.js +6 -4
  18. package/dist/tools/doc-replace-wrapper.test.js +81 -0
  19. package/dist/tools/docs-ai.js +4 -0
  20. package/dist/tools/docs.js +25 -14
  21. package/dist/tools/docs.test.js +25 -12
  22. package/dist/tools/embedding-status.js +3 -0
  23. package/dist/tools/knowledge.js +2 -2
  24. package/dist/tools/knowledge.test.js +8 -0
  25. package/dist/tools/list-tickets.js +12 -9
  26. package/dist/tools/phase-b-tools.test.js +18 -0
  27. package/dist/tools/responsibilities.js +10 -0
  28. package/dist/tools/responsibilities.test.js +60 -0
  29. package/dist/tools/search.js +5 -3
  30. package/dist/tools/semantic-degraded-wrapper.test.js +70 -0
  31. package/dist/tools/semantic-degraded.test.js +65 -0
  32. package/dist/tools/session-start.js +8 -2
  33. package/dist/tools/shared.js +31 -3
  34. package/dist/tools/spec-schemas.js +2 -0
  35. package/dist/tools/wiki.js +15 -9
  36. package/dist/tools/wiki.test.js +23 -0
  37. package/dist/toolset.js +3 -2
  38. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  * @see ORB-1004, ORB-1015, ORB-912, ORB-911
5
5
  */
6
6
  import { z } from 'zod';
7
- import { resolveProjectByKey } from './shared.js';
7
+ import { replacedLines, resolveProjectByKey } from './shared.js';
8
8
  export const listDocSpacesToolConfig = {
9
9
  title: 'List doc spaces',
10
10
  description: 'List wiki spaces (global and project-scoped) the caller can read. Each space contains a tree of docs accessed via orboto_get_doc.',
@@ -311,11 +311,12 @@ export function makeCreateDocHandler(client) {
311
311
  }
312
312
  export const updateDocToolConfig = {
313
313
  title: 'Update a doc page',
314
- description: 'Patch a doc\'s title / content / visibility / parent / icon. Content-only updates do not require sending the title. Every body or title change snapshots the previous version into the revision history automatically - agents can roll back via orboto_restore_doc_revision (when that tool ships in Phase 5). Re-embeds the doc when content or title change.',
314
+ description: 'Patch a doc; content is appended unless replace. ORB-2468: title, visibility, parent and icon are set as given; content goes in as a new section (identical content twice is a no-op); replace: true replaces the whole page and the answer names the revision holding the previous body plus its restore command. Every body or title change snapshots a revision; orboto_restore_doc_revision rolls back. For one passage or section use orboto_edit_doc.',
315
315
  inputSchema: z.object({
316
316
  docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
317
317
  title: z.string().min(1).max(255).optional(),
318
- content: z.string().optional().describe('Markdown body (CommonMark + GFM). Send the full new content; the API replaces, not appends. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown reads as neutral, label <= 24 chars).'),
318
+ content: z.string().optional().describe('Markdown to append (with replace: the whole body). Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown reads as neutral, label <= 24 chars).'),
319
+ replace: z.boolean().optional(),
319
320
  parentDocId: z.string().uuid().nullable().optional(),
320
321
  visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional(),
321
322
  icon: z.string().nullish(),
@@ -328,27 +329,37 @@ export function makeUpdateDocHandler(client) {
328
329
  const body = {};
329
330
  if (input.title !== undefined)
330
331
  body.title = input.title;
331
- if (input.content !== undefined)
332
- body.content = input.content;
332
+ if (input.content !== undefined && input.replace)
333
+ Object.assign(body, { content: input.content, replace: true });
333
334
  if (input.parentDocId !== undefined)
334
335
  body.parentDocId = input.parentDocId;
335
336
  if (input.visibility !== undefined)
336
337
  body.visibility = input.visibility;
337
338
  if (input.icon !== undefined)
338
339
  body.icon = input.icon;
339
- if (Object.keys(body).length === 0) {
340
+ const append = input.content !== undefined && !input.replace;
341
+ if (Object.keys(body).length === 0 && !append) {
340
342
  throw new Error('Pass at least one field to update.');
341
343
  }
342
- const row = await client.patch(`/docs/${input.docId}`, body);
344
+ if (append && !input.content.trim())
345
+ throw new Error('Nothing to append - pass content, or replace: true to replace the whole page.');
346
+ const row = Object.keys(body).length > 0 ? await client.patch(`/docs/${input.docId}`, body) : null;
347
+ const appended = append ? (await client.post(`/docs/${input.docId}/append-section`, { content: input.content })).appended : null;
348
+ const current = row ?? await client.get(`/docs/${input.docId}`);
349
+ const replaced = row?.replaced ?? null;
350
+ const how = appended === null ? '' : appended ? '\n content: appended as a new section' : '\n content: no change - an identical section is already present';
343
351
  return {
344
- content: [{ type: 'text', text: `Updated doc: ${row.title}\n id: ${row.id}\n updatedAt: ${row.updatedAt}` }],
352
+ content: [{ type: 'text', text: `Updated doc: ${current.title}\n id: ${current.id}\n updatedAt: ${current.updatedAt}${how}${replacedLines(replaced ? [replaced] : [])}` }],
345
353
  structuredContent: {
346
- id: row.id,
347
- title: row.title,
348
- spaceId: row.spaceId,
349
- parentDocId: row.parentDocId,
350
- visibility: row.visibility,
351
- updatedAt: row.updatedAt,
354
+ id: current.id,
355
+ title: current.title,
356
+ spaceId: current.spaceId,
357
+ parentDocId: current.parentDocId,
358
+ visibility: current.visibility,
359
+ updatedAt: current.updatedAt,
360
+ mode: append ? 'append' : input.content !== undefined ? 'replace' : 'fields',
361
+ appended,
362
+ replaced,
352
363
  },
353
364
  };
354
365
  };
@@ -232,25 +232,38 @@ describe('orboto_update_doc', () => {
232
232
  it('resolves a doc key to the UUID before writing', async () => {
233
233
  const calls = stubJSON([
234
234
  { json: DOC }, // by-key resolve
235
- { json: { ...DOC, content: 'appended' } }, // the PATCH
235
+ { json: { ...DOC, title: 'Renamed' } }, // the PATCH
236
236
  ]);
237
- await makeUpdateDocHandler(client)({ docId: 'DOC-4', content: 'appended' });
237
+ await makeUpdateDocHandler(client)({ docId: 'DOC-4', title: 'Renamed' });
238
238
  expect(calls[0]).toMatchObject({ method: 'GET', url: 'https://orboto.example.com/docs/by-key/DOC-4' });
239
239
  expect(calls[1]).toMatchObject({
240
240
  method: 'PATCH',
241
241
  url: `https://orboto.example.com/docs/${DOC.id}`,
242
- body: { content: 'appended' },
242
+ body: { title: 'Renamed' },
243
243
  });
244
244
  });
245
- it('PATCHes only the content field when only content was supplied', async () => {
246
- const calls = stubJSON([{ json: { ...DOC, content: 'new body' } }]);
247
- await makeUpdateDocHandler(client)({ docId: DOC.id, content: 'new body' });
248
- expect(calls[0]).toMatchObject({
249
- method: 'PATCH',
250
- url: `https://orboto.example.com/docs/${DOC.id}`,
251
- body: { content: 'new body' },
252
- });
253
- expect(Object.keys(calls[0].body)).toEqual(['content']);
245
+ it('ORB-2468 - appends content as a section and never PATCHes the body without replace', async () => {
246
+ const calls = stubJSON([{ json: { appended: true } }, { json: DOC }]);
247
+ const res = await makeUpdateDocHandler(client)({ docId: DOC.id, content: 'new fact' });
248
+ expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/docs/${DOC.id}/append-section`, body: { content: 'new fact' } });
249
+ expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
250
+ expect(res.structuredContent).toMatchObject({ mode: 'append', appended: true, replaced: null });
251
+ expect(res.content[0].text).toContain('appended as a new section');
252
+ });
253
+ it('ORB-2468 - replace: true PATCHes the whole body and prints the replaced revision with the restore command', async () => {
254
+ const replaced = { docId: DOC.id, revisionId: 'bbbbbbbb-0000-4000-8000-000000000000', restoreCommand: `orboto doc restore DOC-4 bbbbbbbb-0000-4000-8000-000000000000` };
255
+ const calls = stubJSON([{ json: { ...DOC, content: 'new body', replaced } }]);
256
+ const res = await makeUpdateDocHandler(client)({ docId: DOC.id, content: 'new body', replace: true });
257
+ expect(calls[0]).toMatchObject({ method: 'PATCH', url: `https://orboto.example.com/docs/${DOC.id}`, body: { content: 'new body', replace: true } });
258
+ expect(res.structuredContent).toMatchObject({ mode: 'replace', replaced });
259
+ const text = res.content[0].text;
260
+ expect(text).toContain(replaced.revisionId);
261
+ expect(text).toContain(replaced.restoreCommand);
262
+ });
263
+ it('ORB-2468 - refuses an empty append before hitting the API', async () => {
264
+ const calls = stubJSON([]);
265
+ await expect(makeUpdateDocHandler(client)({ docId: DOC.id, content: ' ' })).rejects.toThrow(/Nothing to append/);
266
+ expect(calls).toHaveLength(0);
254
267
  });
255
268
  it('refuses the no-op call before hitting the API', async () => {
256
269
  const calls = stubJSON([]);
@@ -42,6 +42,9 @@ export function makeEmbeddingStatusHandler(client) {
42
42
  lines.push(`Coverage: ${o.embedded} / ${o.embeddable} embeddable embedded · ${o.pending} pending${o.noContent > 0 ? ` · ${o.noContent} with no embeddable content (excluded)` : ''}`);
43
43
  lines.push(` tickets ${s.coverage.ticket.embedded}/${s.coverage.ticket.embeddable} · comments ${s.coverage.comment.embedded}/${s.coverage.comment.embeddable} · docs ${s.coverage.doc.embedded}/${s.coverage.doc.embeddable} (embeddable)`);
44
44
  lines.push(`Circuit breaker: ${s.breaker.tripped ? `TRIPPED - ${s.breaker.lastTrippedReason ?? 'unknown reason'} (${s.breaker.consecutiveFailures} consecutive failures)` : 'ok'}`);
45
+ if (s.breaker.interactive?.tripped) {
46
+ lines.push(`Query breaker: TRIPPED until ${s.breaker.interactive.trippedUntil ?? 'unknown'} - ${s.breaker.interactive.lastTrippedReason ?? 'unknown reason'}; search, ask-docs and duplicate checks serve full-text results until it closes.`);
47
+ }
45
48
  if (s.billingGate?.gated) {
46
49
  lines.push(`BILLING GATE: managed AI is paused for billing${s.billingGate.reason ? ` (${s.billingGate.reason})` : ''} since ${s.billingGate.blockedAt ?? 'unknown'}. The workspace admin resolves this in their orboto account; queued items embed automatically after the unblock.`);
47
50
  }
@@ -1,6 +1,6 @@
1
1
  /** ORB-2225 - the wiki as the agents' knowledge base: ask it first, file durable facts in it. */
2
2
  import { z } from 'zod';
3
- import { mcpInstanceToken } from './shared.js';
3
+ import { mcpInstanceToken, SEMANTIC_DEGRADED_LINE } from './shared.js';
4
4
  import { droppedLines } from './wiki.js';
5
5
  export const knowledgeAskToolConfig = {
6
6
  title: 'Ask the wiki',
@@ -14,7 +14,7 @@ export function makeKnowledgeAskHandler(client) {
14
14
  const where = res.spaces.map((s) => s.spaceKey ?? s.spaceName).join(', ');
15
15
  const cites = res.citations.map((c) => `[${c.index}] ${c.title} - ${c.link}`).join('\n');
16
16
  return {
17
- content: [{ type: 'text', text: [res.answer, cites ? `Sources:\n${cites}` : '', `Asked: ${where}.`].filter(Boolean).join('\n\n') }],
17
+ content: [{ type: 'text', text: [res.answer, cites ? `Sources:\n${cites}` : '', `Asked: ${where}.`, res.semanticDegraded ? `(${SEMANTIC_DEGRADED_LINE})` : ''].filter(Boolean).join('\n\n') }],
18
18
  structuredContent: res,
19
19
  };
20
20
  };
@@ -34,6 +34,14 @@ describe('ORB-2225 orboto_knowledge_ask and orboto_knowledge_add', () => {
34
34
  expect(textOf(result)).toContain('- Runner (target ACME-D99): unknown_target');
35
35
  expect(result.structuredContent).toMatchObject({ droppedOps });
36
36
  });
37
+ it('ORB-2468 - a repeated add returns the first request it answered with, nothing filed again', async () => {
38
+ const duplicate = { of: 'ingesting', requestedAt: '2026-10-16T08:00:00Z', run: { id: '44444444-0000-4000-8000-000000000000', status: 'pending_review' } };
39
+ const post = vi.fn().mockResolvedValue({ action: 'duplicate', duplicate, text: 'Already ingesting as source ACME-D9 "Runner notes" in the ACME-S1 wiki (/s/d, run 44444444-0000-4000-8000-000000000000 pending_review); not filed again.', pages: [], source: { id: 'd9', key: 'ACME-D9', title: 'Runner notes', webUrl: '/s/d' }, droppedOps: [] });
40
+ const result = await makeKnowledgeAddHandler({ post })({ title: 'Runner notes', content: 'x', projectKey: 'ACME' });
41
+ expect(post).toHaveBeenCalledTimes(1);
42
+ expect(textOf(result)).toContain('Already ingesting as source ACME-D9');
43
+ expect(result.structuredContent).toMatchObject({ action: 'duplicate', duplicate });
44
+ });
37
45
  it('both tools carry a title and annotations; ask is read-only, add is not', () => {
38
46
  expect(knowledgeAskToolConfig.title).toBeTruthy();
39
47
  expect(knowledgeAskToolConfig.annotations.readOnlyHint).toBe(true);
@@ -1,4 +1,4 @@
1
- import { TicketSpecStateSchema } from './spec-schemas.js';
1
+ import { TicketListSortSchema, TicketSpecStateSchema } from './spec-schemas.js';
2
2
  /**
3
3
  * ORB-244 Phase B - `orboto_list_tickets`.
4
4
  *
@@ -13,18 +13,18 @@ import { resolveProjectByKey, resolveTicketByKey, ticketLine, agentTicketListRow
13
13
  import { resolveMilestoneByNameOrId } from './milestones.js';
14
14
  export const listTicketsToolConfig = {
15
15
  title: 'List tickets',
16
- description: 'List tickets in a project, filtered by status category, milestone or assignee email. unscheduled=true = the backlog (no milestone yet). Up to 50 per call.',
16
+ description: 'List a project\'s tickets by status category, milestone or assignee email. unscheduled=true = the backlog (no milestone yet). Up to 50 per call.',
17
17
  inputSchema: z.object({
18
- projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
18
+ projectKey: z.string().min(1).describe('Project key.'),
19
19
  specState: TicketSpecStateSchema.optional(),
20
20
  statusCategory: z
21
21
  .enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix'])
22
22
  .optional()
23
- .describe('One workflow category. Omit for all.'),
23
+ .describe('One workflow category.'),
24
24
  open: z
25
25
  .boolean()
26
26
  .optional()
27
- .describe('true = only todo, in_progress, in_review. Not with statusCategory.'),
27
+ .describe('true = todo, in_progress, in_review. Not with statusCategory.'),
28
28
  milestone: z
29
29
  .string()
30
30
  .optional()
@@ -32,16 +32,17 @@ export const listTicketsToolConfig = {
32
32
  unscheduled: z
33
33
  .boolean()
34
34
  .optional()
35
- .describe('true = only tickets with no milestone. Not with `milestone`.'),
35
+ .describe('true = no milestone. Not with `milestone`.'),
36
36
  assigneeEmail: z
37
37
  .string()
38
38
  .optional()
39
- .describe('Project-member email.'),
39
+ .describe('Member email.'),
40
40
  parentTicketKey: z
41
41
  .string()
42
42
  .optional()
43
- .describe('Only children of this ticket - walks an epic.'),
44
- limit: z.number().int().min(1).max(50).default(25).describe('Max rows to return.'),
43
+ .describe('Children of this ticket.'),
44
+ sort: TicketListSortSchema.optional().describe('Newest first; default updated.'),
45
+ limit: z.number().int().min(1).max(50).default(25).describe('Max rows.'),
45
46
  verbose: z.boolean().default(false).describe('true = full rows.'),
46
47
  }).shape,
47
48
  annotations: { readOnlyHint: true, idempotentHint: true },
@@ -60,6 +61,8 @@ export function makeListTicketsHandler(client) {
60
61
  }
61
62
  if (input.open)
62
63
  qs.set('open', 'true');
64
+ if (input.sort)
65
+ qs.set('sort', input.sort);
63
66
  if (input.unscheduled && input.milestone) {
64
67
  throw new Error('Pass either `milestone` or `unscheduled: true`, not both.');
65
68
  }
@@ -8,7 +8,9 @@
8
8
  * break, which is exactly what we want.
9
9
  */
10
10
  import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
11
+ import { readFileSync } from 'node:fs';
11
12
  import { OrbotoClient } from '../orboto-client.js';
13
+ import { TicketListSortSchema } from './spec-schemas.js';
12
14
  import { makeGetProjectHandler } from './get-project.js';
13
15
  import { makeListTicketsHandler } from './list-tickets.js';
14
16
  import { makeGetTicketHandler } from './get-ticket.js';
@@ -80,6 +82,22 @@ describe('orboto_list_tickets', () => {
80
82
  expect(calls[1]).toContain('unscheduled=true');
81
83
  expect(calls[1]).toContain('statusCategory=todo');
82
84
  });
85
+ it('passes sort=created and leaves the default order to the API (ORB-2546)', async () => {
86
+ const calls = stub([
87
+ { json: PROJ },
88
+ { json: { items: [], nextCursor: null } },
89
+ { json: PROJ },
90
+ { json: { items: [], nextCursor: null } },
91
+ ]);
92
+ await makeListTicketsHandler(client)({ projectKey: 'ACME', sort: 'created' });
93
+ expect(calls[1]).toContain('sort=created');
94
+ await makeListTicketsHandler(client)({ projectKey: 'ACME' });
95
+ expect(calls[3]).not.toContain('sort=');
96
+ });
97
+ it('mirrors the shared TicketListSortSchema options (ORB-2546)', () => {
98
+ const shared = readFileSync(new URL('../../../../packages/shared-schema/src/tickets.ts', import.meta.url), 'utf8');
99
+ expect(shared).toContain(`TicketListSortSchema = z.enum(${JSON.stringify([...TicketListSortSchema.options]).replace(/"/g, "'").replace(/,/g, ', ')})`);
100
+ });
83
101
  it('refuses unscheduled together with milestone', async () => {
84
102
  stub([{ json: PROJ }]);
85
103
  await expect(makeListTicketsHandler(client)({ projectKey: 'ACME', unscheduled: true, milestone: 'ACME-M1' })).rejects.toThrow(/not both/);
@@ -0,0 +1,10 @@
1
+ function person(u) {
2
+ return u ? u.fullName ?? u.email : '';
3
+ }
4
+ /** One match as a line: owner address, topic, human owner, why it matched. */
5
+ export function ownerLine(m) {
6
+ const human = m.entry.humanOwner ? `, human ${person(m.entry.humanOwner)}` : '';
7
+ const deputy = m.entry.deputy ? `, deputy ${person(m.entry.deputy)}` : '';
8
+ const why = m.matched.length ? `${m.reason}: ${m.matched.join(', ')}` : m.reason;
9
+ return `- ${m.entry.ownerAddress} - ${m.entry.topic}${human}${deputy} (score ${m.score}, ${why}${m.routes ? '' : ', too weak to route'})`;
10
+ }
@@ -0,0 +1,60 @@
1
+ /** ORB-2542 - the owner check of orboto_agent_notify and orboto_who for. */
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { OrbotoClient } from '../orboto-client.js';
4
+ import { ownerLine } from './responsibilities.js';
5
+ import { makeAgentNotifyHandler } from './agent-coordination.js';
6
+ import { makeAgentWhoHandler } from './agent-who.js';
7
+ afterEach(() => { vi.restoreAllMocks(); });
8
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
9
+ const match = {
10
+ entry: { id: '6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0', topic: 'Game development', ownerAddress: 'worker@GAMES', keywords: ['unity'], humanOwner: { email: 'owner@orboto.test', fullName: 'Owner Person' }, deputy: null },
11
+ score: 0.8, reason: 'keyword', matched: ['unity', 'asset'], routes: true,
12
+ };
13
+ function respond(status, body, urls = [], bodies = []) {
14
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
15
+ urls.push(String(url));
16
+ if (init?.body)
17
+ bodies.push(JSON.parse(init.body));
18
+ return { ok: status < 400, status, statusText: 'x', json: async () => body, text: async () => JSON.stringify(body) };
19
+ });
20
+ }
21
+ describe('ownerLine', () => {
22
+ it('prints owner address, topic, human owner and why it matched, and marks a weak match', () => {
23
+ expect(ownerLine(match)).toBe('- worker@GAMES - Game development, human Owner Person (score 0.8, keyword: unity, asset)');
24
+ expect(ownerLine({ ...match, routes: false, reason: 'embedding', matched: [] })).toContain('embedding, too weak to route');
25
+ });
26
+ });
27
+ describe('orboto_agent_notify owner check', () => {
28
+ const request = { to: 'integrator@OPS', kind: 'request', subject: 'Unity asset on storage', outcome: 'The asset is stored for the game.' };
29
+ it('sends ownerCheck refuse and turns 409 owner_mismatch into a tool error naming the owner and both ways on', async () => {
30
+ const bodies = [];
31
+ respond(409, { error: 'x', errorKey: 'errors.agents.owner_mismatch', errorParams: { topic: 'Game development', owner: 'worker@GAMES', target: 'integrator@OPS', ownerHint: { text: '"Game development" belongs to worker@GAMES, not integrator@OPS' } } }, [], bodies);
32
+ const result = await makeAgentNotifyHandler(client)(request);
33
+ expect(bodies[0].ownerCheck).toBe('refuse');
34
+ expect(result.isError).toBe(true);
35
+ const text = result.content[0].text;
36
+ expect(text).toContain('belongs to worker@GAMES');
37
+ expect(text).toContain('route: true');
38
+ expect(text).toContain('forceTarget: true');
39
+ });
40
+ it('passes route on and reports the owner it went to', async () => {
41
+ const bodies = [];
42
+ respond(200, { ok: true, messageId: '6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f1', toSessionId: null, routedTo: 'session', recipients: [{ label: 'w@orboto.test (worker, aaaaaaaa)', sessionId: null }, { label: 'w2@orboto.test (worker, bbbbbbbb)', sessionId: null }], ownerHint: { ownerAddress: 'worker@GAMES', topic: 'Game development', target: 'integrator@OPS', routed: true, text: '' } }, [], bodies);
43
+ const result = await makeAgentNotifyHandler(client)({ ...request, route: true });
44
+ expect(bodies[0]).toMatchObject({ route: true, ownerCheck: 'refuse' });
45
+ expect(result.content[0].text).toContain('routed to the owner of "Game development" instead of integrator@OPS');
46
+ expect(result.structuredContent).toMatchObject({ owner: 'worker@GAMES' });
47
+ });
48
+ });
49
+ describe('orboto_who for', () => {
50
+ it('passes for, names the owner, and points at the queue when nobody answers', async () => {
51
+ const urls = [];
52
+ respond(200, { sessions: [], owner: match }, urls);
53
+ const result = await makeAgentWhoHandler(client)({ for: 'unity asset' });
54
+ expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?for=unity+asset');
55
+ const text = result.content[0].text;
56
+ expect(text).toContain('Owner:\n- worker@GAMES');
57
+ expect(text).toContain('No live session answers to worker@GAMES - send with queue: true and project GAMES');
58
+ expect(result.structuredContent).toMatchObject({ owner: expect.objectContaining({ reason: 'keyword' }) });
59
+ });
60
+ });
@@ -4,7 +4,7 @@
4
4
  * @see ORB-272
5
5
  */
6
6
  import { z } from 'zod';
7
- import { resolveProjectByKey } from './shared.js';
7
+ import { resolveProjectByKey, SEMANTIC_DEGRADED_LINE } from './shared.js';
8
8
  const SEARCH_TYPES = ['ticket', 'comment', 'doc', 'project', 'milestone', 'space', 'customer'];
9
9
  export const searchToolConfig = {
10
10
  title: 'Search across orboto',
@@ -35,7 +35,8 @@ export function makeSearchHandler(client) {
35
35
  const relaxedNote = res.pass === 'relaxed'
36
36
  ? ' [relaxed pass: no row matched ALL terms - these matched some terms or the meaning]'
37
37
  : '';
38
- const text = res.items.length === 0
38
+ const degradedNote = res.semanticDegraded ? `(${SEMANTIC_DEGRADED_LINE})\n` : '';
39
+ const text = degradedNote + (res.items.length === 0
39
40
  ? `No hits for "${input.query}".`
40
41
  : `Hits${headerHint}${relaxedNote}:\n\n` + res.items.map((h) => {
41
42
  const tag = h.type.toUpperCase();
@@ -43,7 +44,7 @@ export function makeSearchHandler(client) {
43
44
  const project = h.projectName && h.type !== 'project' ? ` · ${h.projectName}` : '';
44
45
  const excerpt = h.excerpt ? `\n ${h.excerpt}` : '';
45
46
  return `- [${tag} ${ident}${project}] ${h.title}${excerpt}`;
46
- }).join('\n\n');
47
+ }).join('\n\n'));
47
48
  return {
48
49
  content: [{ type: 'text', text }],
49
50
  structuredContent: {
@@ -51,6 +52,7 @@ export function makeSearchHandler(client) {
51
52
  shown,
52
53
  hasMore,
53
54
  pass: res.pass ?? 'strict',
55
+ semanticDegraded: res.semanticDegraded ?? false,
54
56
  hits: res.items.map((h) => ({
55
57
  type: h.type,
56
58
  id: h.id, // ORB-1084 - full id, usable as write-tool input
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ORB-2543 - the skill wrapper names a full-text-only answer on stderr and
3
+ * keeps stdout unchanged.
4
+ */
5
+ import { expect, it } from 'vitest';
6
+ import { cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
7
+ import { createServer } from 'node:http';
8
+ import { spawn } from 'node:child_process';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ const LINE = 'semantic search unavailable, showing full-text results';
12
+ it('search, search-docs, ask-docs and check-similar print the degraded line on stderr', async () => {
13
+ const dir = await mkdtemp(join(tmpdir(), 'orboto-2543-wrapper-'));
14
+ const server = createServer(async (req, res) => {
15
+ for await (const chunk of req)
16
+ void chunk;
17
+ const url = req.url ?? '';
18
+ res.setHeader('Content-Type', 'application/json');
19
+ if (url === '/projects')
20
+ res.end(JSON.stringify([{ id: 'p', key: 'ORB' }]));
21
+ else if (url.startsWith('/projects/by-key/'))
22
+ res.end(JSON.stringify({ id: 'p', key: 'ORB' }));
23
+ else if (url.startsWith('/search?'))
24
+ res.end(JSON.stringify({ items: [{ type: 'ticket', id: 't', ticketKey: 'ORB-1', title: 'Frobnicator jams', excerpt: '' }], nextCursor: null, total: 1, semanticDegraded: true }));
25
+ else if (url.startsWith('/docs/search?'))
26
+ res.end(JSON.stringify({ items: [], total: 0, mode: 'fts', semantic: false, semanticDegraded: true }));
27
+ else if (url === '/ai/ask-docs')
28
+ res.end(JSON.stringify({ answer: 'Restart it [1].', citations: [{ index: 1, title: 'Runbook', link: '/l' }], mode: 'keyword', abstained: false, semanticDegraded: true }));
29
+ else if (url.startsWith('/projects/p/tickets/similar?'))
30
+ res.end(JSON.stringify({ candidates: [], mode: 'tsvector', semanticDegraded: true }));
31
+ else {
32
+ res.statusCode = 404;
33
+ res.end(JSON.stringify({ error: `unexpected ${url}` }));
34
+ }
35
+ });
36
+ try {
37
+ await mkdir(join(dir, 'skills/orboto'), { recursive: true });
38
+ await cp(new URL('../../../../skills/orboto/scripts', import.meta.url), join(dir, 'skills/orboto/scripts'), { recursive: true });
39
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
40
+ const address = server.address();
41
+ if (!address || typeof address === 'string')
42
+ throw new Error('Fixture address missing');
43
+ const origin = `http://127.0.0.1:${address.port}`;
44
+ const guard = join(dir, 'network.mjs');
45
+ await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused nonlocal request'); return original(input, init); };`);
46
+ const run = (args) => new Promise((ok, no) => {
47
+ const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
48
+ cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: origin, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
49
+ timeout: 20_000,
50
+ });
51
+ let out = '', err = '';
52
+ child.stdout.on('data', (chunk) => { out += chunk.toString(); });
53
+ child.stderr.on('data', (chunk) => { err += chunk.toString(); });
54
+ child.on('error', no);
55
+ child.on('close', (code) => code === 0 ? ok({ out, err }) : no(new Error(`Wrapper exit ${code}: ${err}`)));
56
+ child.stdin.end('');
57
+ });
58
+ for (const args of [['search', 'frobnicator'], ['search-docs', 'frobnicator'], ['ask-docs', 'how to restart'], ['check-similar', 'ORB', 'Frobnicator jams']]) {
59
+ const { out, err } = await run(args);
60
+ expect(err, args.join(' ')).toContain(LINE);
61
+ expect(out, args.join(' ')).not.toContain(LINE);
62
+ }
63
+ }
64
+ finally {
65
+ server.closeAllConnections();
66
+ if (server.listening)
67
+ await new Promise((ok, no) => server.close((err) => err ? no(err) : ok()));
68
+ await rm(dir, { recursive: true, force: true });
69
+ }
70
+ }, 60_000);
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ORB-2543 - every search-shaped tool prints one line when the API answered
3
+ * from full text because the query embedding timed out or the breaker is open.
4
+ */
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { OrbotoClient } from '../orboto-client.js';
7
+ import { makeSearchHandler } from './search.js';
8
+ import { makeSearchDocsHandler } from './doc-edits.js';
9
+ import { makeAskDocsHandler } from './docs-ai.js';
10
+ import { makeWikiAskHandler } from './wiki.js';
11
+ import { makeKnowledgeAskHandler } from './knowledge.js';
12
+ import { makeCheckSimilarHandler } from './check-similar.js';
13
+ import { SEMANTIC_DEGRADED_LINE } from './shared.js';
14
+ afterEach(() => { vi.restoreAllMocks(); });
15
+ function stub(responses) {
16
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
17
+ const json = responses.shift();
18
+ if (json === undefined)
19
+ throw new Error('unexpected extra fetch');
20
+ return { ok: true, status: 200, statusText: 'OK', json: async () => json, text: async () => JSON.stringify(json) };
21
+ });
22
+ }
23
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
24
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
25
+ const text = (r) => r.content[0].text;
26
+ const ANSWER = { answer: 'Restart it [1].', citations: [{ index: 1, title: 'Runbook', link: '/d/1' }], mode: 'keyword', abstained: false };
27
+ describe('ORB-2543 - semanticDegraded reaches the agent', () => {
28
+ it('orboto_search prints the line and carries the flag', async () => {
29
+ stub([{ items: [], nextCursor: null, total: 0, semanticDegraded: true }]);
30
+ const res = await makeSearchHandler(client)({ query: 'frobnicator' });
31
+ expect(text(res)).toContain(SEMANTIC_DEGRADED_LINE);
32
+ expect(res.structuredContent.semanticDegraded).toBe(true);
33
+ });
34
+ it('orboto_search stays silent when the semantic path worked', async () => {
35
+ stub([{ items: [], nextCursor: null, total: 0, semanticDegraded: false }]);
36
+ const res = await makeSearchHandler(client)({ query: 'frobnicator' });
37
+ expect(text(res)).not.toContain(SEMANTIC_DEGRADED_LINE);
38
+ });
39
+ it('orboto_search_docs prints the line on hits and on an empty result', async () => {
40
+ stub([{ items: [], total: 0, mode: 'fts', semantic: false, semanticDegraded: true }]);
41
+ const empty = await makeSearchDocsHandler(client)({ q: 'frobnicator' });
42
+ expect(text(empty)).toContain(SEMANTIC_DEGRADED_LINE);
43
+ stub([{
44
+ items: [{ id: 'd1', docKey: 'DOC-1', title: 'Runbook', spaceId: 's1', spaceName: 'Ops', projectId: null, snippet: 'restart', headingPath: [], charOffset: 0, lineOffset: 1, rank: 1, url: '/d/1' }],
45
+ total: 1, mode: 'fts', semantic: false, semanticDegraded: true,
46
+ }]);
47
+ const hits = await makeSearchDocsHandler(client)({ q: 'frobnicator' });
48
+ expect(text(hits)).toContain(SEMANTIC_DEGRADED_LINE);
49
+ expect(hits.structuredContent.semanticDegraded).toBe(true);
50
+ });
51
+ it('ask-docs, wiki ask and knowledge ask print the line', async () => {
52
+ stub([{ ...ANSWER, semanticDegraded: true }]);
53
+ expect(text(await makeAskDocsHandler(client)({ question: 'how to restart' }))).toContain(SEMANTIC_DEGRADED_LINE);
54
+ stub([{ ...ANSWER, semanticDegraded: true }]);
55
+ expect(text(await makeWikiAskHandler(client)({ question: 'how to restart' }))).toContain(SEMANTIC_DEGRADED_LINE);
56
+ stub([{ ...ANSWER, semanticDegraded: true, source: 'declared', spaces: [{ spaceKey: 'ORB-S1', spaceName: 'Wiki', projectKey: 'ORB' }] }]);
57
+ expect(text(await makeKnowledgeAskHandler(client)({ question: 'how to restart' }))).toContain(SEMANTIC_DEGRADED_LINE);
58
+ });
59
+ it('orboto_check_similar prints the line', async () => {
60
+ stub([PROJ, { candidates: [], mode: 'tsvector', semanticDegraded: true }]);
61
+ const res = await makeCheckSimilarHandler(client)({ projectKey: 'ACME', title: 'Frobnicator jams' });
62
+ expect(text(res)).toContain(SEMANTIC_DEGRADED_LINE);
63
+ expect(res.structuredContent.semanticDegraded).toBe(true);
64
+ });
65
+ });
@@ -22,7 +22,7 @@ export const sessionStartToolConfig = {
22
22
  title: 'Load the rules you must follow + re-orient',
23
23
  description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Registers this session (a heartbeat on its instance row) and is otherwise read-only. '
24
24
  + '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; the returned session.ref is what peers pass as toSessionRef (ORB-2136). '
25
- + 'The digest also carries the Knowledge block (ORB-2225): the wiki index of your declared projects and of the Operations wiki, their latest log entries and open lint issues; ask the wiki with orboto_knowledge_ask before asking a person, file durable facts with orboto_knowledge_add.',
25
+ + 'The digest also carries the Knowledge block (ORB-2225): the wiki index of your declared projects and of the Operations wiki, their latest log entries and open lint issues; ask the wiki with orboto_knowledge_ask before asking a person, file durable facts with orboto_knowledge_add. It also carries the Responsibilities block (ORB-2542): who owns the topics of your declared projects, looked up with orboto_who for before you send agent mail.',
26
26
  inputSchema: z.object({
27
27
  projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
28
28
  ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
@@ -211,7 +211,7 @@ export function makeSessionStartHandler(client, opts = {}) {
211
211
  if (input.scope === undefined && registration && !registration.scope && envProjectScope()) {
212
212
  registration = toRegistration(await declareEnvProjectScope(client, instanceToken)) ?? registration;
213
213
  }
214
- const [me, rules, assigned, timer, inboxRaw, knowledge] = await Promise.all([
214
+ const [me, rules, assigned, timer, inboxRaw, knowledge, owners] = await Promise.all([
215
215
  client.get('/users/me').catch(() => null),
216
216
  loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
217
217
  client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
@@ -222,6 +222,9 @@ export function makeSessionStartHandler(client, opts = {}) {
222
222
  client.get('/knowledge', { instanceToken })
223
223
  .then((r) => (typeof r?.text === 'string' && Array.isArray(r.spaces) && Array.isArray(r.missing) ? r : null))
224
224
  .catch(() => null),
225
+ client.get('/responsibilities/block', { instanceToken })
226
+ .then((r) => (typeof r?.text === 'string' && r.text ? { text: r.text, scoped: Array.isArray(r.entries) ? r.entries.length : 0, total: r.total ?? 0 } : null))
227
+ .catch(() => null),
225
228
  ]);
226
229
  const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, from: m.from?.label ?? m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
227
230
  const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
@@ -299,6 +302,8 @@ export function makeSessionStartHandler(client, opts = {}) {
299
302
  const knowledgeBlock = knowledge?.text ? knowledgeLines(knowledge.text, client) : null;
300
303
  if (knowledgeBlock)
301
304
  lines.push('', ...knowledgeBlock.lines);
305
+ if (owners)
306
+ lines.push('', owners.text);
302
307
  lines.push('', '## This session');
303
308
  const scopeText = registration?.scope
304
309
  ? [registration.scope.role ? `role ${registration.scope.role}` : null, registration.scope.projectKeys?.length ? `projects ${registration.scope.projectKeys.join(', ')}` : null, registration.scope.ticketKeys?.length ? `tickets ${registration.scope.ticketKeys.join(', ')}` : null].filter(Boolean).join('; ')
@@ -353,6 +358,7 @@ export function makeSessionStartHandler(client, opts = {}) {
353
358
  healthyCount: gitHealthWithConnections.reduce((n, p) => n + p.connections.filter((c) => c.healthy).length, 0),
354
359
  },
355
360
  ...(knowledge ? { knowledge: knowledgeSummary(knowledge, knowledgeBlock?.handle) } : {}),
361
+ ...(owners ? { responsibilities: { scoped: owners.scoped, total: owners.total } } : {}),
356
362
  ...(pendingMessages.length > 0 ? { pendingMessages } : {}),
357
363
  ...(bundle ? { ticketBundle: bundle.structured } : {}),
358
364
  },
@@ -1,4 +1,12 @@
1
1
  import { OrbotoApiError } from '../orboto-client.js';
2
+ /** ORB-2543 - the line every search-shaped tool prints when the API flags `semanticDegraded`. */
3
+ export const SEMANTIC_DEGRADED_LINE = 'semantic search unavailable, showing full-text results';
4
+ /** ORB-2468 - the pages a write replaced, each with the revision holding the previous body and its restore command. */
5
+ export function replacedLines(replaced) {
6
+ if (replaced.length === 0)
7
+ return '';
8
+ return `\nReplaced: ${replaced.length}\n${replaced.map((r) => `- ${r.docId}: previous body in revision ${r.revisionId}, restore with: ${r.restoreCommand}`).join('\n')}`;
9
+ }
2
10
  export async function resolveProjectByKey(client, key) {
3
11
  try {
4
12
  return await client.get(`/projects/by-key/${encodeURIComponent(key)}`);
@@ -121,10 +129,30 @@ export function localSessionHost(extra, host = hostname()) {
121
129
  const value = host.trim().toLowerCase().slice(0, 200);
122
130
  return value || undefined;
123
131
  }
124
- /** Precedence: explicit caller-supplied token > per-connection MCP session id
125
- * (distinct per client even on a shared HTTP server) > per-process id (stdio). */
132
+ /** ORB-2544 - the stable token an HTTP MCP session acts under, bound by the transport for the session's lifetime. */
133
+ const sessionInstances = new Map();
134
+ /** The HTTP transport binds a transport session to the stable token of its client. */
135
+ export function bindSessionInstance(sessionId, token) {
136
+ sessionInstances.set(sessionId, token);
137
+ }
138
+ /** Drops the binding; answers whether another live session still acts under the same token. */
139
+ export function releaseSessionInstance(sessionId) {
140
+ const token = sessionInstances.get(sessionId) ?? null;
141
+ sessionInstances.delete(sessionId);
142
+ return { token, shared: token !== null && [...sessionInstances.values()].includes(token) };
143
+ }
144
+ /** ORB-2544 - one token per account, credential and client name: every reconnect of that client resumes one session row. */
145
+ export function stableClientInstance(input) {
146
+ if (!input.credential?.id)
147
+ return null;
148
+ const client = (input.client ?? '').split('@')[0].trim().toLowerCase();
149
+ const key = createHash('sha256').update([input.userEmail.toLowerCase(), input.credential.kind, input.credential.id, client].join('\n')).digest('hex').slice(0, 16);
150
+ return `mcp-${key}`;
151
+ }
152
+ /** Precedence: explicit caller-supplied token > the stable token the transport bound to the MCP session (ORB-2544), else the
153
+ * per-connection session id > per-process id (stdio). */
126
154
  export function mcpInstanceToken(explicit, extra) {
127
- return explicit ?? (extra?.sessionId ? `mcp-${extra.sessionId}` : MCP_PROCESS_INSTANCE);
155
+ return explicit ?? (extra?.sessionId ? (sessionInstances.get(extra.sessionId) ?? `mcp-${extra.sessionId}`) : MCP_PROCESS_INSTANCE);
128
156
  }
129
157
  /** ORB-2140 - the stdio process's own instance token, the one the inbox channel follows the stream with. */
130
158
  export function mcpProcessInstance() {
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  export const TicketSpecStateSchema = z.enum(['none', 'needs_spec', 'in_spec', 'ready']);
3
+ /** ORB-2546 - mirror of TicketListSortSchema in @orboto/shared-schema (the MCP package does not depend on it; drift test in phase-b-tools.test.ts). */
4
+ export const TicketListSortSchema = z.enum(['updated', 'created']);
3
5
  export const ProjectSpecSettingsSchema = z.object({
4
6
  enabled: z.boolean().optional(),
5
7
  requiredTypes: z.array(z.enum(['epic', 'story', 'task', 'bug'])).optional(),