@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.
- package/dist/event-bridge.js +2 -0
- package/dist/http-transport.js +28 -6
- package/dist/inbox-channel.js +14 -11
- package/dist/instructions-budget.test.js +72 -2
- package/dist/manifest-size.test.js +8 -0
- package/dist/orboto-client.js +3 -1
- package/dist/orboto-client.test.js +1 -1
- package/dist/server.js +18 -28
- package/dist/session-origin.js +100 -0
- package/dist/session-origin.test.js +127 -0
- package/dist/tools/agent-coordination.js +39 -11
- package/dist/tools/agent-coordination.test.js +53 -35
- package/dist/tools/agent-messages.js +16 -5
- package/dist/tools/agent-who.js +23 -8
- package/dist/tools/agent-who.test.js +12 -0
- package/dist/tools/check-similar.js +5 -3
- package/dist/tools/doc-edits.js +6 -4
- package/dist/tools/doc-replace-wrapper.test.js +81 -0
- package/dist/tools/docs-ai.js +4 -0
- package/dist/tools/docs.js +25 -14
- package/dist/tools/docs.test.js +25 -12
- package/dist/tools/embedding-status.js +3 -0
- package/dist/tools/knowledge.js +2 -2
- package/dist/tools/knowledge.test.js +8 -0
- package/dist/tools/list-tickets.js +12 -9
- package/dist/tools/phase-b-tools.test.js +18 -0
- package/dist/tools/responsibilities.js +10 -0
- package/dist/tools/responsibilities.test.js +60 -0
- package/dist/tools/search.js +5 -3
- package/dist/tools/semantic-degraded-wrapper.test.js +70 -0
- package/dist/tools/semantic-degraded.test.js +65 -0
- package/dist/tools/session-start.js +8 -2
- package/dist/tools/shared.js +31 -3
- package/dist/tools/spec-schemas.js +2 -0
- package/dist/tools/wiki.js +15 -9
- package/dist/tools/wiki.test.js +23 -0
- package/dist/toolset.js +3 -2
- package/package.json +1 -1
|
@@ -43,9 +43,16 @@ export function makeAgentHeartbeatHandler(client) {
|
|
|
43
43
|
};
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
+
export function originText(s) {
|
|
47
|
+
if (!s.origin)
|
|
48
|
+
return '';
|
|
49
|
+
const o = s.origin;
|
|
50
|
+
const where = [o.host ?? 'unknown host', o.repo, o.pid ? `pid ${o.pid}` : null, o.harness, o.client ? `${o.client}${o.clientVersion ? ` ${o.clientVersion}` : ''}` : null].filter(Boolean).join(' ');
|
|
51
|
+
return ` [${s.state ?? 'live'}] on ${where}${o.startedAt ? ` since ${o.startedAt}` : ''}`;
|
|
52
|
+
}
|
|
46
53
|
export const agentPresenceToolConfig = {
|
|
47
54
|
title: 'Workspace agent presence',
|
|
48
|
-
description: '
|
|
55
|
+
description: 'Active agent instances. The orboto_who set: lane workers and reviewers, inbox, MCP and runner connections; rows carry kind (agent | human, by credential; isBot is its alias), actsAs, owner, projects, connections, lane, sessionId, userEmail, runtime (clientInfo.name), capabilities, status, current ticket, reconnects, origin (host, repo, pid, client, harness, credential) and state (live | quiet | orphaned | unknown_origin). Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those declared on their projects, a foreign row redacted (redacted: true, no pid, repo, machine, connections, inbox or owner). Check it before dispatching a sub-task. projectKey and kind filter client-side like `orboto agents --project KEY --bots|--humans`.',
|
|
49
56
|
inputSchema: z.object({
|
|
50
57
|
projectKey: z.string().min(1).max(64).optional().describe('Keep only rows whose projects contain this project key (case-insensitive).'),
|
|
51
58
|
kind: AgentInventoryKindSchema.optional().describe('Keep only rows classified as agent or human by their credential.'),
|
|
@@ -62,7 +69,7 @@ export function filterPresenceRows(rows, args) {
|
|
|
62
69
|
}
|
|
63
70
|
export function makeAgentPresenceHandler(client) {
|
|
64
71
|
return async (args = {}) => {
|
|
65
|
-
const all = await client.get('/v1/agent/inventory');
|
|
72
|
+
const all = (await client.get('/v1/agent/who?include=inventory')).inventory ?? [];
|
|
66
73
|
const sessions = filterPresenceRows(all, args);
|
|
67
74
|
const lines = [];
|
|
68
75
|
if (sessions.length === 0) {
|
|
@@ -76,7 +83,7 @@ export function makeAgentPresenceHandler(client) {
|
|
|
76
83
|
const work = s.workingOnTicket
|
|
77
84
|
? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
|
|
78
85
|
: '';
|
|
79
|
-
lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${work}; owner: ${s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self')}${s.lane ? `; lane: ${s.lane.name}` : ''}${s.projects.length ? `; projects: ${s.projects.map((p) => p.key).join(', ')}` : ''}${s.connections.length ? `; connections: ${s.connections.map((c) => c.type).join(', ')}` : ''}`);
|
|
86
|
+
lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self')}${s.lane ? `; lane: ${s.lane.name}` : ''}${s.projects.length ? `; projects: ${s.projects.map((p) => p.key).join(', ')}` : ''}${s.connections.length ? `; connections: ${s.connections.map((c) => c.type).join(', ')}` : ''}`);
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
return {
|
|
@@ -106,9 +113,26 @@ export function noRecipientText(err) {
|
|
|
106
113
|
+ (live.length ? `Live sessions: ${live.join('; ')}. ` : 'No live session declared the project. ')
|
|
107
114
|
+ 'Address one of them, or send again with queue: true and project to keep it until a session declares the project.';
|
|
108
115
|
}
|
|
116
|
+
/** ORB-2542 - the 409 `owner_mismatch` as a tool error naming the owner and both ways on. */
|
|
117
|
+
export function ownerMismatchText(err) {
|
|
118
|
+
if (err.status !== 409)
|
|
119
|
+
return null;
|
|
120
|
+
let body;
|
|
121
|
+
try {
|
|
122
|
+
body = JSON.parse(err.body);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (body.errorKey !== 'errors.agents.owner_mismatch')
|
|
128
|
+
return null;
|
|
129
|
+
const p = body.errorParams ?? {};
|
|
130
|
+
const text = p.ownerHint?.text ?? `"${p.topic ?? '?'}" belongs to ${p.owner ?? '?'}, not ${p.target ?? '?'}`;
|
|
131
|
+
return `not sent: ${text}. Send again with route: true to deliver it to ${p.owner ?? 'the owner'}, or forceTarget: true to keep the target.`;
|
|
132
|
+
}
|
|
109
133
|
export const agentNotifyToolConfig = {
|
|
110
|
-
title: 'Notify
|
|
111
|
-
description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account
|
|
134
|
+
title: 'Notify an agent or user',
|
|
135
|
+
description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session.',
|
|
112
136
|
inputSchema: z.object({
|
|
113
137
|
targetEmail: z.string().email().optional(),
|
|
114
138
|
to: z.string().min(3).max(200).optional(),
|
|
@@ -118,9 +142,11 @@ export const agentNotifyToolConfig = {
|
|
|
118
142
|
outcome: z.string().min(10).max(1000).optional(),
|
|
119
143
|
payload: z.record(z.string(), z.unknown()).optional(),
|
|
120
144
|
threadId: z.string().uuid().optional(),
|
|
121
|
-
project: z.string().min(1).max(64).optional()
|
|
122
|
-
senderRef: z.string().min(1).max(128).optional()
|
|
145
|
+
project: z.string().min(1).max(64).optional(),
|
|
146
|
+
senderRef: z.string().min(1).max(128).optional(),
|
|
123
147
|
toSessionRef: z.string().min(1).max(200).optional(),
|
|
148
|
+
route: z.boolean().optional(),
|
|
149
|
+
forceTarget: z.boolean().optional(),
|
|
124
150
|
}).shape,
|
|
125
151
|
outputSchema: z.object({
|
|
126
152
|
ok: z.literal(true),
|
|
@@ -130,6 +156,7 @@ export const agentNotifyToolConfig = {
|
|
|
130
156
|
routingNote: z.string().optional(),
|
|
131
157
|
recipients: z.array(z.string()).optional(),
|
|
132
158
|
queued: z.boolean().optional(),
|
|
159
|
+
owner: z.string().optional(),
|
|
133
160
|
}).shape,
|
|
134
161
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
135
162
|
};
|
|
@@ -178,26 +205,27 @@ export function makeAgentNotifyHandler(client) {
|
|
|
178
205
|
const senderRef = mcpInstanceToken(args.senderRef, extra);
|
|
179
206
|
let res;
|
|
180
207
|
try {
|
|
181
|
-
res = await client.post('/v1/agent/notify', { ...args, senderRef }, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
208
|
+
res = await client.post('/v1/agent/notify', { ...args, senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
182
209
|
}
|
|
183
210
|
catch (err) {
|
|
184
|
-
const refused = err instanceof OrbotoApiError ? noRecipientText(err) : null;
|
|
211
|
+
const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) : null;
|
|
185
212
|
if (refused)
|
|
186
213
|
return { isError: true, content: [{ type: 'text', text: refused }] };
|
|
187
214
|
throw err;
|
|
188
215
|
}
|
|
189
|
-
const who = args.to ?? args.targetEmail ?? '?';
|
|
216
|
+
const who = res.ownerHint?.routed ? res.ownerHint.ownerAddress : args.to ?? args.targetEmail ?? '?';
|
|
190
217
|
const recipients = (res.recipients ?? []).map((r) => r.label);
|
|
191
218
|
const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${who} session ${res.toSessionId.slice(0, 8)}` : who;
|
|
192
219
|
const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
|
|
193
220
|
const note = res.routingNote ? ` - ${res.routingNote}` : '';
|
|
194
221
|
return {
|
|
195
|
-
content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})${res.hint ? `\nhint: ${res.hint}` : ''}` }],
|
|
222
|
+
content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})${res.ownerHint?.routed ? `\nrouted to the owner of "${res.ownerHint.topic}" instead of ${res.ownerHint.target}` : ''}${res.hint ? `\nhint: ${res.hint}` : ''}` }],
|
|
196
223
|
structuredContent: {
|
|
197
224
|
ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo,
|
|
198
225
|
...(res.routingNote ? { routingNote: res.routingNote } : {}),
|
|
199
226
|
...(recipients.length ? { recipients } : {}),
|
|
200
227
|
...(res.queued ? { queued: true } : {}),
|
|
228
|
+
...(res.ownerHint?.routed ? { owner: res.ownerHint.ownerAddress } : {}),
|
|
201
229
|
},
|
|
202
230
|
};
|
|
203
231
|
};
|
|
@@ -44,47 +44,49 @@ describe('orboto_agent_heartbeat', () => {
|
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
describe('orboto_agent_presence', () => {
|
|
47
|
-
it('GETs /v1/agent/
|
|
47
|
+
it('GETs the inventory through /v1/agent/who and renders one line per session with its origin and state (ORB-2544)', async () => {
|
|
48
48
|
stub([{
|
|
49
|
-
json: [
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
49
|
+
json: { sessions: [], inventory: [
|
|
50
|
+
{
|
|
51
|
+
userId: '00000000-0000-0000-0000-000000000001',
|
|
52
|
+
userEmail: 'alice@x.test',
|
|
53
|
+
userFullName: 'Alice', kind: 'human', isBot: false, actsAs: null, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [], workSessions: [],
|
|
54
|
+
sessionId: '00000000-0000-0000-0000-000000000010',
|
|
55
|
+
status: 'working',
|
|
56
|
+
workingOnTicket: { id: '00000000-0000-0000-0000-000000000020', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
|
|
57
|
+
capabilities: ['writes-tickets'],
|
|
58
|
+
clientInfo: { name: 'claude-code' },
|
|
59
|
+
lastSeenAt: new Date().toISOString(),
|
|
60
|
+
createdAt: new Date().toISOString(),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
userId: '00000000-0000-0000-0000-000000000002',
|
|
64
|
+
userEmail: 'bot@x.test',
|
|
65
|
+
userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' },
|
|
66
|
+
autonomyPaused: false, lane: null, projects: [{ id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'api_key', label: 'dispatcher' }], workSessions: [],
|
|
67
|
+
sessionId: '00000000-0000-0000-0000-000000000011',
|
|
68
|
+
status: 'idle',
|
|
69
|
+
workingOnTicket: null,
|
|
70
|
+
capabilities: [],
|
|
71
|
+
clientInfo: { name: 'dispatcher-daemon' },
|
|
72
|
+
lastSeenAt: new Date().toISOString(),
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
origin: { host: 'build-host', repo: 'sample-repo', pid: 4242, client: 'orboto-cli', clientVersion: '1.2.3', harness: 'claude-code', startedAt: null },
|
|
75
|
+
state: 'orphaned',
|
|
76
|
+
},
|
|
77
|
+
] },
|
|
76
78
|
}]);
|
|
77
79
|
const handler = makeAgentPresenceHandler(client);
|
|
78
80
|
const result = await handler();
|
|
79
81
|
const text = result.content[0].text;
|
|
80
82
|
expect(text).toContain('2 active session(s)');
|
|
81
83
|
expect(text).toContain('Alice <alice@x.test> (claude-code, instance 00000000-0000-0000-0000-000000000010) - working · working on [ORB] ORB-42');
|
|
82
|
-
expect(text).toContain('bot@x.test <bot@x.test> (dispatcher-daemon, instance 00000000-0000-0000-0000-000000000011) - idle; owner: Alice');
|
|
83
|
-
expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/inventory');
|
|
84
|
+
expect(text).toContain('bot@x.test <bot@x.test> (dispatcher-daemon, instance 00000000-0000-0000-0000-000000000011) - idle [orphaned] on build-host sample-repo pid 4242 claude-code orboto-cli 1.2.3; owner: Alice');
|
|
85
|
+
expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory');
|
|
84
86
|
expect(result.structuredContent.sessions).toHaveLength(2);
|
|
85
87
|
});
|
|
86
88
|
it('handles an empty workspace gracefully', async () => {
|
|
87
|
-
stub([{ json: [] }]);
|
|
89
|
+
stub([{ json: { sessions: [], inventory: [] } }]);
|
|
88
90
|
const handler = makeAgentPresenceHandler(client);
|
|
89
91
|
const result = await handler();
|
|
90
92
|
expect(result.content[0].text).toBe('No active agent sessions in the workspace.');
|
|
@@ -97,9 +99,9 @@ describe('orboto_agent_presence', () => {
|
|
|
97
99
|
it('filters by projectKey (case-insensitive, projects only) and kind on the client side (ORB-2135)', async () => {
|
|
98
100
|
const handler = makeAgentPresenceHandler(client);
|
|
99
101
|
const ids = async (args) => {
|
|
100
|
-
stub([{ json: presenceRows() }]);
|
|
102
|
+
stub([{ json: { sessions: [], inventory: presenceRows() } }]);
|
|
101
103
|
const result = await handler(args);
|
|
102
|
-
expect(vi.mocked(fetch).mock.calls.at(-1)?.[0]).toBe('https://orboto.example.com/v1/agent/inventory');
|
|
104
|
+
expect(vi.mocked(fetch).mock.calls.at(-1)?.[0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory');
|
|
103
105
|
return result.structuredContent.sessions.map((s) => s.sessionId.slice(-2));
|
|
104
106
|
};
|
|
105
107
|
expect(await ids({})).toEqual(['10', '11', '12']);
|
|
@@ -110,12 +112,12 @@ describe('orboto_agent_presence', () => {
|
|
|
110
112
|
expect(await ids({ projectKey: 'NONE' })).toEqual([]);
|
|
111
113
|
});
|
|
112
114
|
it('says when a filter matched nothing but the workspace is not empty', async () => {
|
|
113
|
-
stub([{ json: presenceRows() }]);
|
|
115
|
+
stub([{ json: { sessions: [], inventory: presenceRows() } }]);
|
|
114
116
|
const result = await makeAgentPresenceHandler(client)({ projectKey: 'NONE' });
|
|
115
117
|
expect(result.content[0].text).toBe('No active agent sessions match the filter (3 active in the workspace).');
|
|
116
118
|
});
|
|
117
119
|
it('renders acts-as, lane, projects and connection types on each line', async () => {
|
|
118
|
-
stub([{ json: presenceRows() }]);
|
|
120
|
+
stub([{ json: { sessions: [], inventory: presenceRows() } }]);
|
|
119
121
|
const text = (await makeAgentPresenceHandler(client)({ kind: 'agent' })).content[0].text;
|
|
120
122
|
expect(text).toContain('2 active session(s)');
|
|
121
123
|
expect(text).toContain('owner: Alice; lane: worker-1; projects: ACME, ORB; connections: lane, live_events');
|
|
@@ -228,6 +230,22 @@ describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
|
|
|
228
230
|
expect(urls[0]).toContain('excludeRef=mcp-abc123');
|
|
229
231
|
await handler({ includeOwnSends: true }, { sessionId: 'abc123' });
|
|
230
232
|
expect(urls[1]).not.toContain('excludeRef');
|
|
233
|
+
expect(urls[1]).toContain('includeOwn=true');
|
|
234
|
+
});
|
|
235
|
+
it('ORB-2547: an own send renders as sent with the recipient session, its delivered/read state and the thread', async () => {
|
|
236
|
+
const party = (label, sessionId) => ({ userId: 'u', email: 'e', sessionId, role: null, label });
|
|
237
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
238
|
+
ok: true, status: 200, statusText: 'OK',
|
|
239
|
+
json: async () => ({ sessionId: '11111111-1111-4111-8111-111111111111', messages: [
|
|
240
|
+
{ id: '22222222-2222-4222-8222-222222222222', fromUserId: 'u', toUserId: 'v', from: party('me (11111111)', '11111111'), to: party('peer (44444444)', '44444444'), kind: 'complete', subject: 'done', payload: null, threadId: '33333333-3333-4333-8333-333333333333', projectKey: 'ALPHA', createdAt: '2026-10-16T10:00:00Z', deliveredAt: '2026-10-16T10:00:05Z', readAt: null, dismissed: null, own: true },
|
|
241
|
+
] }),
|
|
242
|
+
text: async () => '',
|
|
243
|
+
}));
|
|
244
|
+
const result = await makeAgentMessagesHandler(client)({ includeOwnSends: true }, { sessionId: 'abc123' });
|
|
245
|
+
const text = result.content[0].text;
|
|
246
|
+
expect(text).toContain('[sent] delivered 2026-10-16T10:00:05Z, unread [complete] [ALPHA] done (from me (11111111) to peer (44444444)');
|
|
247
|
+
expect(text).toContain('thread 33333333-3333-4333-8333-333333333333');
|
|
248
|
+
expect(result.structuredContent.messages[0]).toMatchObject({ own: true, deliveredAt: '2026-10-16T10:00:05Z', threadId: '33333333-3333-4333-8333-333333333333' });
|
|
231
249
|
});
|
|
232
250
|
});
|
|
233
251
|
describe('ORB-2136 - session addressing', () => {
|
|
@@ -14,16 +14,16 @@ const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId:
|
|
|
14
14
|
const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
|
|
15
15
|
export const agentMessagesToolConfig = {
|
|
16
16
|
title: 'Agent inbox and message work',
|
|
17
|
-
description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true is the account view
|
|
17
|
+
description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out (includeOwnSends lists your sends with recipient session, delivered/read state, thread), all:true is the account view in full (ORB-2136). Default: the newest 10 unread, newest first, a header plus 300 payload characters each, then `N unread, M older than 7 days`; limit and since (ISO time or message id) narrow it. Before orboto_session_start only account mail is listed; register to see mail addressed to your session (ORB-2347). messageWork carries durable work ownership (mutation schema: orboto_api_search) - claim before executing. ackIds (message ids) acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss: 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).',
|
|
18
18
|
inputSchema: z.object({
|
|
19
19
|
messageWork: z.record(z.unknown()).optional()
|
|
20
|
-
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}
|
|
20
|
+
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}.'),
|
|
21
21
|
all: z.boolean().default(false),
|
|
22
22
|
limit: z.number().int().min(1).max(200).optional(),
|
|
23
23
|
since: z.string().optional(),
|
|
24
24
|
project: z.string().min(1).max(64).optional().describe('Key/UUID; includes unscoped mail.'),
|
|
25
25
|
includeOwnSends: z.boolean().default(false),
|
|
26
|
-
ackIds: z.array(z.string().uuid()).max(200).optional()
|
|
26
|
+
ackIds: z.array(z.string().uuid()).max(200).optional(),
|
|
27
27
|
dismiss: z.object({
|
|
28
28
|
ids: z.array(z.string().uuid()).min(1).max(200),
|
|
29
29
|
reason: z.enum(['not_mine', 'obsolete', 'duplicate']),
|
|
@@ -45,7 +45,9 @@ export const agentMessagesToolConfig = {
|
|
|
45
45
|
threadId: z.string().nullable(),
|
|
46
46
|
projectKey: z.string().nullable(),
|
|
47
47
|
createdAt: z.string(),
|
|
48
|
+
deliveredAt: z.string().nullable().optional(),
|
|
48
49
|
readAt: z.string().nullable(),
|
|
50
|
+
own: z.boolean().optional(),
|
|
49
51
|
dismissed: z.object({
|
|
50
52
|
reason: z.string(), note: z.string().nullable(), duplicateOf: z.string().nullable(), createdAt: z.string(),
|
|
51
53
|
}).nullable(),
|
|
@@ -66,6 +68,12 @@ function compactLines(m, header) {
|
|
|
66
68
|
...(rest > 0 ? [` ... ${rest} more characters (all:true prints everything)`] : []),
|
|
67
69
|
];
|
|
68
70
|
}
|
|
71
|
+
/** ORB-2547 - an own send's state at the recipient. */
|
|
72
|
+
function sentState(m) {
|
|
73
|
+
if (m.readAt)
|
|
74
|
+
return `read ${m.readAt}`;
|
|
75
|
+
return m.deliveredAt ? `delivered ${m.deliveredAt}, unread` : 'not delivered yet';
|
|
76
|
+
}
|
|
69
77
|
export function makeAgentMessagesHandler(client) {
|
|
70
78
|
return async (args, extra) => {
|
|
71
79
|
if (args.messageWork) {
|
|
@@ -103,10 +111,12 @@ export function makeAgentMessagesHandler(client) {
|
|
|
103
111
|
if (args.project)
|
|
104
112
|
q.set('project', args.project);
|
|
105
113
|
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
106
|
-
if (
|
|
114
|
+
if (args.includeOwnSends)
|
|
115
|
+
q.set('includeOwn', 'true');
|
|
116
|
+
else
|
|
107
117
|
q.set('excludeRef', instanceToken);
|
|
108
118
|
const { messages, sessionId, counts } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
|
|
109
|
-
const header = (m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
|
|
119
|
+
const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
|
|
110
120
|
const lines = messages.length === 0
|
|
111
121
|
? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
|
|
112
122
|
: messages.flatMap((m) => (compact ? compactLines(m, header(m)) : [`${header(m)}${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`]));
|
|
@@ -130,6 +140,7 @@ export function makeAgentMessagesHandler(client) {
|
|
|
130
140
|
id: m.id, fromUserId: m.fromUserId, from: m.from, to: m.to, kind: m.kind, subject: m.subject,
|
|
131
141
|
payload: compact ? null : m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
|
|
132
142
|
createdAt: m.createdAt, readAt: m.readAt, dismissed: m.dismissed ?? null,
|
|
143
|
+
...(m.own ? { own: true, deliveredAt: m.deliveredAt ?? null } : {}),
|
|
133
144
|
})),
|
|
134
145
|
acked,
|
|
135
146
|
dismissed: dismissedCount,
|
package/dist/tools/agent-who.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/** ORB-2264 - the live directory: who a sender can address before sending. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { AgentSessionRoleSchema } from './agent-session-scope.js';
|
|
4
|
+
import { ownerLine } from './responsibilities.js';
|
|
5
|
+
import { originText } from './agent-coordination.js';
|
|
4
6
|
export const agentWhoToolConfig = {
|
|
5
|
-
title: 'Who
|
|
6
|
-
description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. Each row names account, role, declared projects, last seen, wake channel and current ticket; address a role from here, never a session id copied from a log.',
|
|
7
|
+
title: 'Who receives agent mail',
|
|
8
|
+
description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. Each row names account, role, declared projects, last seen, wake channel and current ticket; address a role from here, never a session id copied from a log. `for` (free text) names the topic owner and keeps its sessions; `state` keeps one state (orphaned: only another process touches it).',
|
|
7
9
|
inputSchema: z.object({
|
|
8
10
|
project: z.string().min(1).max(64).optional(),
|
|
9
11
|
role: AgentSessionRoleSchema.optional(),
|
|
12
|
+
for: z.string().min(2).max(2000).optional(),
|
|
13
|
+
state: z.enum(['live', 'quiet', 'orphaned', 'unknown_origin']).optional(),
|
|
10
14
|
}).shape,
|
|
11
|
-
outputSchema: z.object({ sessions: z.array(z.
|
|
15
|
+
outputSchema: z.object({ sessions: z.array(z.unknown()), owner: z.unknown().optional() }).shape,
|
|
12
16
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
13
17
|
};
|
|
14
18
|
/** One directory row as a line: address, account, session, projects, channel, ticket. */
|
|
@@ -16,20 +20,31 @@ export function whoLine(s) {
|
|
|
16
20
|
const address = s.addresses.join(', ') || (s.role ? `${s.role} (no project)` : 'no scope');
|
|
17
21
|
const channel = s.channel.announced ? `${s.channel.announced}${s.channel.online ? ' online' : ' offline'}` : 'no channel';
|
|
18
22
|
const ticket = s.currentTicket ? `; on ${s.currentTicket.key ?? s.currentTicket.title}` : '';
|
|
19
|
-
const host = s.host ? `, host ${s.host}` : '';
|
|
20
|
-
|
|
23
|
+
const host = s.origin ? originText(s) : s.host ? `, host ${s.host}` : '';
|
|
24
|
+
const shared = s.sharedAddresses?.length ? `; also held by another live session: ${s.sharedAddresses.join(', ')}` : '';
|
|
25
|
+
return `- ${address} - ${s.email} session ${s.shortId}${host}, ${s.status}, ${channel}, last seen ${s.lastSeenAt}${ticket}${shared}`;
|
|
21
26
|
}
|
|
22
27
|
export function makeAgentWhoHandler(client) {
|
|
23
28
|
return async (args = {}) => {
|
|
24
29
|
const qs = new URLSearchParams();
|
|
30
|
+
if (args.state)
|
|
31
|
+
qs.set('state', args.state);
|
|
25
32
|
if (args.project)
|
|
26
33
|
qs.set('project', args.project);
|
|
27
34
|
if (args.role)
|
|
28
35
|
qs.set('role', args.role);
|
|
36
|
+
if (args.for)
|
|
37
|
+
qs.set('for', args.for);
|
|
29
38
|
const res = await client.get(`/v1/agent/who${qs.size ? `?${qs.toString()}` : ''}`);
|
|
39
|
+
const ownerLines = args.for
|
|
40
|
+
? [res.owner ? `Owner:\n${ownerLine(res.owner)}` : `No entry in the responsibility map matches "${args.for}".`]
|
|
41
|
+
: [];
|
|
30
42
|
const lines = res.sessions.length
|
|
31
|
-
? [`${res.sessions.length} live session(s):`, ...res.sessions.map(whoLine)]
|
|
32
|
-
: ['No live session matches - a request sent now answers no_recipient unless it is queued.'];
|
|
33
|
-
return {
|
|
43
|
+
? [...ownerLines, `${res.sessions.length} live session(s):`, ...res.sessions.map(whoLine)]
|
|
44
|
+
: [...ownerLines, res.owner ? `No live session answers to ${res.owner.entry.ownerAddress} - send with queue: true and project ${res.owner.entry.ownerAddress.split('@')[1]} to keep it until one declares it.` : 'No live session matches - a request sent now answers no_recipient unless it is queued.'];
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
47
|
+
structuredContent: { sessions: res.sessions, ...(args.for ? { owner: (res.owner ?? null) } : {}) },
|
|
48
|
+
};
|
|
34
49
|
};
|
|
35
50
|
}
|
|
@@ -31,4 +31,16 @@ describe('orboto_who', () => {
|
|
|
31
31
|
it('ORB-2465: names the host a live notice filters on', () => {
|
|
32
32
|
expect(whoLine({ ...entry, host: 'build-host-a' })).toContain('session f072e1c5, host build-host-a, working');
|
|
33
33
|
});
|
|
34
|
+
it('ORB-2544: prints origin, state and a shared address, and passes the state filter', async () => {
|
|
35
|
+
const line = whoLine({ ...entry, state: 'orphaned', sharedAddresses: ['integrator@ORB'], origin: { host: 'build-host', repo: 'sample-repo', pid: 4242, client: 'orboto-cli', clientVersion: '1.2.3', harness: 'claude-code', startedAt: '2026-10-16T08:00:00.000Z' } });
|
|
36
|
+
expect(line).toContain('session f072e1c5 [orphaned] on build-host sample-repo pid 4242 claude-code orboto-cli 1.2.3 since 2026-10-16T08:00:00.000Z, working');
|
|
37
|
+
expect(line).toContain('also held by another live session: integrator@ORB');
|
|
38
|
+
const urls = [];
|
|
39
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
40
|
+
urls.push(String(url));
|
|
41
|
+
return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [] }) };
|
|
42
|
+
});
|
|
43
|
+
await makeAgentWhoHandler(client)({ state: 'unknown_origin' });
|
|
44
|
+
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?state=unknown_origin');
|
|
45
|
+
});
|
|
34
46
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { trimSimilarEntries } from './similar-projection.js';
|
|
6
|
-
import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
|
|
6
|
+
import { resolveProjectByKey, resolveTicketByKey, SEMANTIC_DEGRADED_LINE } from './shared.js';
|
|
7
7
|
export const checkSimilarToolConfig = {
|
|
8
8
|
title: 'Check for similar tickets before creating',
|
|
9
9
|
description: 'Run the duplicate check against a proposed title + description without creating anything. The pipeline is tsvector plus an AI-embedding rerank when configured. 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.',
|
|
@@ -43,7 +43,8 @@ export function makeCheckSimilarHandler(client) {
|
|
|
43
43
|
: strong
|
|
44
44
|
? `HIGH-SIMILARITY MATCH FOUND - review [${strong.ticketKey ?? strong.id.slice(0, 8)}] "${strong.title}" before creating; this may already be tracked.`
|
|
45
45
|
: 'Possible related tickets - review the list and decide whether the new ticket adds distinct scope.';
|
|
46
|
-
const
|
|
46
|
+
const degradedNote = result.semanticDegraded ? `(${SEMANTIC_DEGRADED_LINE})\n` : '';
|
|
47
|
+
const text = degradedNote + (result.candidates.length === 0
|
|
47
48
|
? `${recommendation} (match mode: ${result.mode})`
|
|
48
49
|
: [
|
|
49
50
|
recommendation,
|
|
@@ -55,12 +56,13 @@ export function makeCheckSimilarHandler(client) {
|
|
|
55
56
|
const rel = c.relation ? ` (related: ${c.relation})` : '';
|
|
56
57
|
return ` - ${key}${status} (${score})${rel}: ${c.title}`;
|
|
57
58
|
}),
|
|
58
|
-
].join('\n');
|
|
59
|
+
].join('\n'));
|
|
59
60
|
return {
|
|
60
61
|
content: [{ type: 'text', text }],
|
|
61
62
|
structuredContent: {
|
|
62
63
|
similar: trimSimilarEntries(result.candidates),
|
|
63
64
|
mode: result.mode,
|
|
65
|
+
semanticDegraded: result.semanticDegraded ?? false,
|
|
64
66
|
recommendation,
|
|
65
67
|
},
|
|
66
68
|
};
|
package/dist/tools/doc-edits.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
8
8
|
import { resolveDocId } from './docs.js';
|
|
9
|
-
import { resolveProjectByKey } from './shared.js';
|
|
9
|
+
import { resolveProjectByKey, SEMANTIC_DEGRADED_LINE } from './shared.js';
|
|
10
10
|
/** Turn a 409 from POST /docs/:id/edits into a clear, non-throwing tool
|
|
11
11
|
* result the model can act on. Returns null for any non-409 so the caller
|
|
12
12
|
* rethrows (a genuine transport/permission failure should still surface). */
|
|
@@ -100,10 +100,11 @@ export function makeSearchDocsHandler(client) {
|
|
|
100
100
|
if (input.limit)
|
|
101
101
|
qs.set('limit', String(input.limit));
|
|
102
102
|
const res = await client.get(`/docs/search?${qs.toString()}`);
|
|
103
|
+
const degradedNote = res.semanticDegraded ? `(${SEMANTIC_DEGRADED_LINE})\n` : '';
|
|
103
104
|
if (res.items.length === 0) {
|
|
104
105
|
return {
|
|
105
|
-
content: [{ type: 'text', text:
|
|
106
|
-
structuredContent: { items: [], total: 0 },
|
|
106
|
+
content: [{ type: 'text', text: `${degradedNote}No docs matched "${input.q}".` }],
|
|
107
|
+
structuredContent: { items: [], total: 0, semanticDegraded: res.semanticDegraded ?? false },
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
const lines = res.items.map((h) => {
|
|
@@ -112,7 +113,7 @@ export function makeSearchDocsHandler(client) {
|
|
|
112
113
|
return `- ${h.title} (${h.docKey ?? h.id})${anchor}${where}\n ${h.snippet.replace(/\s+/g, ' ').trim()}`;
|
|
113
114
|
});
|
|
114
115
|
return {
|
|
115
|
-
content: [{ type: 'text', text: lines.join('\n') }],
|
|
116
|
+
content: [{ type: 'text', text: degradedNote + lines.join('\n') }],
|
|
116
117
|
structuredContent: {
|
|
117
118
|
items: res.items.map((h) => ({
|
|
118
119
|
id: h.id,
|
|
@@ -129,6 +130,7 @@ export function makeSearchDocsHandler(client) {
|
|
|
129
130
|
url: h.url,
|
|
130
131
|
})),
|
|
131
132
|
total: res.total,
|
|
133
|
+
semanticDegraded: res.semanticDegraded ?? false,
|
|
132
134
|
},
|
|
133
135
|
};
|
|
134
136
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2468 - the skill wrapper's `doc edit` appends content unless --replace,
|
|
3
|
+
* a replace prints the replaced revision and restore command, `doc restore`
|
|
4
|
+
* posts the revision restore.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, it } from 'vitest';
|
|
7
|
+
import { cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
const DOC = 'd0c00000-2222-4222-8222-222222222222';
|
|
13
|
+
const REV = 'bbbbbbbb-0000-4000-8000-000000000000';
|
|
14
|
+
it('doc edit appends by default, --replace names the replaced revision, doc restore posts the restore', async () => {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), 'orboto-2468-wrapper-'));
|
|
16
|
+
const requests = [];
|
|
17
|
+
const server = createServer(async (req, res) => {
|
|
18
|
+
let raw = '';
|
|
19
|
+
for await (const chunk of req)
|
|
20
|
+
raw += String(chunk);
|
|
21
|
+
const url = req.url ?? '';
|
|
22
|
+
requests.push({ method: req.method ?? '', url, body: raw ? JSON.parse(raw) : {} });
|
|
23
|
+
res.setHeader('Content-Type', 'application/json');
|
|
24
|
+
const row = { id: DOC, docKey: 'ORB-D7', title: 'Runbook', webUrl: 'https://orboto.example/docs/ORB-D7' };
|
|
25
|
+
if (req.method === 'POST' && url === '/docs/ORB-D7/append-section')
|
|
26
|
+
res.end(JSON.stringify({ appended: true }));
|
|
27
|
+
else if (req.method === 'PATCH' && url === '/docs/ORB-D7')
|
|
28
|
+
res.end(JSON.stringify({ ...row, replaced: { docId: DOC, revisionId: REV, restoreCommand: `orboto doc restore ORB-D7 ${REV}` } }));
|
|
29
|
+
else if (req.method === 'POST' && url === `/docs/ORB-D7/revisions/${REV}/restore`)
|
|
30
|
+
res.end(JSON.stringify(row));
|
|
31
|
+
else {
|
|
32
|
+
res.statusCode = 404;
|
|
33
|
+
res.end(JSON.stringify({ error: `unexpected ${req.method} ${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) => ok({ code, out, err }));
|
|
56
|
+
child.stdin.end('');
|
|
57
|
+
});
|
|
58
|
+
const appended = await run(['doc', 'edit', 'ORB-D7', '--content', 'new fact']);
|
|
59
|
+
expect(appended.code, appended.err).toBe(0);
|
|
60
|
+
expect(requests.at(-1)).toMatchObject({ method: 'POST', url: '/docs/ORB-D7/append-section', body: { content: 'new fact' } });
|
|
61
|
+
expect(requests.some((r) => r.method === 'PATCH')).toBe(false);
|
|
62
|
+
expect(appended.err).toContain('Appended to doc ORB-D7');
|
|
63
|
+
const replaced = await run(['doc', 'edit', 'ORB-D7', '--replace', '--content', 'whole new body']);
|
|
64
|
+
expect(replaced.code, replaced.err).toBe(0);
|
|
65
|
+
expect(requests.at(-1)).toMatchObject({ method: 'PATCH', url: '/docs/ORB-D7', body: { content: 'whole new body', replace: true } });
|
|
66
|
+
expect(replaced.err).toContain(REV);
|
|
67
|
+
expect(replaced.err).toContain(`orboto doc restore ORB-D7 ${REV}`);
|
|
68
|
+
const refused = await run(['doc', 'edit', 'ORB-D7', '--replace', '--old', 'a', '--new', 'b']);
|
|
69
|
+
expect(refused.code).toBe(2);
|
|
70
|
+
const restored = await run(['doc', 'restore', 'ORB-D7', REV]);
|
|
71
|
+
expect(restored.code, restored.err).toBe(0);
|
|
72
|
+
expect(requests.at(-1)).toMatchObject({ method: 'POST', url: `/docs/ORB-D7/revisions/${REV}/restore` });
|
|
73
|
+
expect(restored.err).toContain('Restored doc ORB-D7');
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
server.closeAllConnections();
|
|
77
|
+
if (server.listening)
|
|
78
|
+
await new Promise((ok, no) => server.close((err) => err ? no(err) : ok()));
|
|
79
|
+
await rm(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}, 60_000);
|
package/dist/tools/docs-ai.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* ORB-799 - docs-AI surface (ask + ingest).
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { SEMANTIC_DEGRADED_LINE } from './shared.js';
|
|
5
6
|
export const askDocsToolConfig = {
|
|
6
7
|
title: 'Ask a question against the wiki (RAG)',
|
|
7
8
|
description: 'Run a natural-language question against the wiki via the workspace\'s RAG pipeline. Returns an `answer` (Markdown) + `citations` array `[{index, title, link, spaceName}]`. The model is the workspace\'s configured AI provider; retrieval requires the embedding provider too - call `orboto_ai_status` to verify both before relying on this. Restrict to a single doc space with `spaceId` when scoping a query (e.g. "only the runbooks space"). Latency is dominated by retrieval + LLM round-trip; expect 2-10s.',
|
|
@@ -29,6 +30,8 @@ export function makeAskDocsHandler(client) {
|
|
|
29
30
|
if (res.abstained)
|
|
30
31
|
lines.push('', '(abstained: no claim in the answer could be tied to a retrieved doc - treat as "not in the docs", do not guess)');
|
|
31
32
|
lines.push('', `(retrieval mode: ${res.mode})`);
|
|
33
|
+
if (res.semanticDegraded)
|
|
34
|
+
lines.push(`(${SEMANTIC_DEGRADED_LINE})`);
|
|
32
35
|
return {
|
|
33
36
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
34
37
|
structuredContent: {
|
|
@@ -36,6 +39,7 @@ export function makeAskDocsHandler(client) {
|
|
|
36
39
|
citations: res.citations,
|
|
37
40
|
mode: res.mode,
|
|
38
41
|
abstained: res.abstained ?? false,
|
|
42
|
+
semanticDegraded: res.semanticDegraded ?? false,
|
|
39
43
|
},
|
|
40
44
|
};
|
|
41
45
|
};
|