@orboto/mcp 0.226.2 → 0.227.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/tools/agent-coordination.js +89 -33
- package/dist/tools/agent-coordination.test.js +62 -22
- package/dist/tools/agent-messages-compact.test.js +3 -3
- package/dist/tools/agent-messages.js +24 -4
- package/dist/tools/agent-presence-row.js +22 -0
- package/dist/tools/agent-who.js +9 -18
- package/dist/tools/agent-who.test.js +9 -2
- package/dist/tools/primer-facts.js +6 -6
- package/dist/tools/read-budget.test.js +161 -0
- package/dist/tools/responsibilities.test.js +1 -1
- package/dist/tools/session-start.test.js +1 -1
- package/package.json +1 -1
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* @see ORB-704, ORB-706
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { AGENT_WAKE_LABELS,
|
|
7
|
+
import { AGENT_WAKE_LABELS, AgentInventoryKindSchema, AgentSessionWakeSchema, } from './agent-presence-schema.js';
|
|
8
|
+
import { AgentPresenceRowSchema } from './agent-presence-row.js';
|
|
8
9
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
9
10
|
import { mcpInstanceToken } from './shared.js';
|
|
10
|
-
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
11
|
+
import { AgentSessionRoleSchema, AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
11
12
|
export const agentHeartbeatToolConfig = {
|
|
12
13
|
title: 'Agent heartbeat (Multi-Agent Coordination)',
|
|
13
14
|
description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it. `startedBy`: starter session id, same account.',
|
|
@@ -54,45 +55,99 @@ export function originText(s) {
|
|
|
54
55
|
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(' ');
|
|
55
56
|
return ` [${s.state ?? 'live'}] on ${where}${o.startedAt ? ` since ${o.startedAt}` : ''}`;
|
|
56
57
|
}
|
|
58
|
+
/** ORB-2548 - the page an MCP presence read returns by default, sized so four rows fit the 4,000-character budget. */
|
|
59
|
+
export const PRESENCE_PAGE_LIMIT = 4;
|
|
60
|
+
export const PresenceFilterShape = {
|
|
61
|
+
role: AgentSessionRoleSchema.optional(),
|
|
62
|
+
state: z.enum(['live', 'quiet', 'orphaned', 'unknown_origin']).optional(),
|
|
63
|
+
email: z.string().min(3).max(320).optional(),
|
|
64
|
+
sessionId: z.string().min(8).max(36).optional(),
|
|
65
|
+
kind: AgentInventoryKindSchema.optional(),
|
|
66
|
+
wake: AgentSessionWakeSchema.optional(),
|
|
67
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
68
|
+
cursor: z.string().max(500).optional(),
|
|
69
|
+
};
|
|
70
|
+
export function presenceQuery(args, extra = {}) {
|
|
71
|
+
const qs = new URLSearchParams(extra);
|
|
72
|
+
for (const key of ['project', 'role', 'state', 'email', 'sessionId', 'kind', 'wake', 'cursor', 'for']) {
|
|
73
|
+
const value = args[key]?.trim();
|
|
74
|
+
if (value)
|
|
75
|
+
qs.set(key, value);
|
|
76
|
+
}
|
|
77
|
+
qs.set('limit', String(args.limit ?? PRESENCE_PAGE_LIMIT));
|
|
78
|
+
return qs.toString();
|
|
79
|
+
}
|
|
80
|
+
/** ORB-2548 - the answer for a project filter the API does not know or the caller may not see (its 404). */
|
|
81
|
+
export function unknownProjectText(key) {
|
|
82
|
+
return `Project "${key.trim().toUpperCase()}" is unknown or not visible to you - no session is listed for it.`;
|
|
83
|
+
}
|
|
84
|
+
/** Runs the presence read; a 404 for a project filter becomes null, so the tool answers unknownProjectText instead of a raw error. */
|
|
85
|
+
export async function readPresence(client, path, project) {
|
|
86
|
+
try {
|
|
87
|
+
return await client.get(path);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (project?.trim() && err instanceof OrbotoApiError && err.status === 404)
|
|
91
|
+
return null;
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The line that ends a page with more rows behind it. */
|
|
96
|
+
export function nextPageLine(nextCursor) {
|
|
97
|
+
return nextCursor ? [`More sessions: call again with cursor "${nextCursor}", or narrow with email, sessionId, project, role, kind, wake or state.`] : [];
|
|
98
|
+
}
|
|
99
|
+
function presenceRow(s) {
|
|
100
|
+
const o = s.origin;
|
|
101
|
+
return {
|
|
102
|
+
sessionId: s.sessionId, userEmail: s.userEmail, userFullName: s.userFullName, kind: s.kind,
|
|
103
|
+
owner: s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : null),
|
|
104
|
+
status: s.status, lane: s.lane?.name ?? null, scope: s.scope ?? null, memberProjects: s.projects.length,
|
|
105
|
+
connections: [...new Set(s.connections.map((c) => c.type))], ...(s.wake ? { wake: s.wake } : {}), ...(s.state ? { state: s.state } : {}),
|
|
106
|
+
origin: o ? { host: o.host, repo: o.repo, pid: o.pid, harness: o.harness, client: o.client } : null,
|
|
107
|
+
workingOn: s.workingOnTicket ? s.workingOnTicket.key ?? s.workingOnTicket.id : null,
|
|
108
|
+
lastSeenAt: String(s.lastSeenAt), ...(s.redacted ? { redacted: true } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function presenceLine(s) {
|
|
112
|
+
const name = s.userFullName ?? s.userEmail;
|
|
113
|
+
const runtime = s.clientInfo.name ?? 'unknown';
|
|
114
|
+
const work = s.workingOnTicket ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})` : '';
|
|
115
|
+
const owner = s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self');
|
|
116
|
+
const declared = s.scope?.projectKeys ?? [];
|
|
117
|
+
const scope = s.scope?.role || declared.length ? `; scope: ${s.scope?.role ?? 'no role'}${declared.length ? ` on ${declared.join(', ')}` : ''}` : '';
|
|
118
|
+
const keys = s.projects.map((p) => p.key);
|
|
119
|
+
const projects = keys.length === 0 ? '' : keys.length <= 8 ? `; projects: ${keys.join(', ')}` : `; member of ${keys.length} projects`;
|
|
120
|
+
const connections = s.connections.length ? `; connections: ${[...new Set(s.connections.map((c) => c.type))].join(', ')}` : '';
|
|
121
|
+
return `- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${owner}${s.lane ? `; lane: ${s.lane.name}` : ''}${scope}${projects}${connections}${s.wake ? `; ${AGENT_WAKE_LABELS[s.wake]}` : ''}`;
|
|
122
|
+
}
|
|
57
123
|
export const agentPresenceToolConfig = {
|
|
58
124
|
title: 'Workspace agent presence',
|
|
59
|
-
description: 'Active agent instances. The orboto_who set: lane workers and reviewers, inbox, MCP and runner connections
|
|
125
|
+
description: 'Active agent instances. The orboto_who set from GET /v1/agent/who?include=inventory: lane workers and reviewers, inbox, MCP and runner connections. Each row: sessionId, account, kind (agent | human by credential), owner or acts-as, status, lane, declared scope, member project count, connection types, wake (on_call: nothing wakes it), state (live | quiet | orphaned | unknown_origin), origin (host, repo, pid, harness, client), current ticket. Filters run on the server: email, sessionId (id or 8+ character prefix), projectKey (sessions declared on it), role, kind, wake, state; 4 rows a page, nextCursor continues (cursor). A projectKey that is unknown or not visible to you answers so in one line, with no rows. Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those on their projects, a foreign row redacted. The full inventory row: `orboto agents --json`.',
|
|
60
126
|
inputSchema: z.object({
|
|
61
|
-
projectKey: z.string().min(1).max(64).optional()
|
|
62
|
-
|
|
127
|
+
projectKey: z.string().min(1).max(64).optional(),
|
|
128
|
+
...PresenceFilterShape,
|
|
63
129
|
}).shape,
|
|
64
130
|
outputSchema: z.object({
|
|
65
|
-
sessions: z.array(
|
|
131
|
+
sessions: z.array(AgentPresenceRowSchema),
|
|
132
|
+
nextCursor: z.string().nullable(),
|
|
66
133
|
}).shape,
|
|
67
134
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
68
135
|
};
|
|
69
|
-
/** Client-side view filter (ORB-2135); the inventory route stays unfiltered and the chat mirror applies the same rule. */
|
|
70
|
-
export function filterPresenceRows(rows, args) {
|
|
71
|
-
const key = args.projectKey?.trim().toUpperCase();
|
|
72
|
-
return rows.filter((row) => (!args.kind || row.kind === args.kind) && (!key || row.projects.some((p) => p.key.toUpperCase() === key)));
|
|
73
|
-
}
|
|
74
136
|
export function makeAgentPresenceHandler(client) {
|
|
75
137
|
return async (args = {}) => {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const runtime = s.clientInfo.name ?? 'unknown';
|
|
87
|
-
const work = s.workingOnTicket
|
|
88
|
-
? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
|
|
89
|
-
: '';
|
|
90
|
-
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(', ')}` : ''}${s.wake ? `; ${AGENT_WAKE_LABELS[s.wake]}` : ''}`);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
138
|
+
const { projectKey, ...filters } = args;
|
|
139
|
+
const project = projectKey ?? filters.project;
|
|
140
|
+
const res = await readPresence(client, `/v1/agent/who?${presenceQuery({ ...filters, project }, { include: 'inventory' })}`, project);
|
|
141
|
+
if (!res)
|
|
142
|
+
return { content: [{ type: 'text', text: unknownProjectText(project) }], structuredContent: { sessions: [], nextCursor: null } };
|
|
143
|
+
const rows = res.inventory ?? [];
|
|
144
|
+
const filtered = Object.entries(args).some(([key, value]) => key !== 'limit' && key !== 'cursor' && value !== undefined && value !== '');
|
|
145
|
+
const lines = rows.length === 0
|
|
146
|
+
? [filtered ? 'No active agent session matches the filter.' : 'No active agent sessions in the workspace.']
|
|
147
|
+
: [`${rows.length} active session(s)${res.nextCursor ? ' on this page' : ''}:`, ...rows.map(presenceLine), ...nextPageLine(res.nextCursor)];
|
|
93
148
|
return {
|
|
94
149
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
95
|
-
structuredContent: { sessions },
|
|
150
|
+
structuredContent: { sessions: rows.map(presenceRow), nextCursor: res.nextCursor ?? null },
|
|
96
151
|
};
|
|
97
152
|
};
|
|
98
153
|
}
|
|
@@ -163,7 +218,7 @@ export function reminderTooSoonText(err) {
|
|
|
163
218
|
}
|
|
164
219
|
export const agentNotifyToolConfig = {
|
|
165
220
|
title: 'Notify an agent or user',
|
|
166
|
-
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). outcome: one sentence, 10 to 1000 characters; body: the plain text of the message (any length, lands in payload.body; payload may carry other 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 to an account email without project or toSessionRef goes to its one live session, with several it is refused listing them (pick one with toSessionRef). 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. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
|
|
221
|
+
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). outcome: one sentence, 10 to 1000 characters; body: the plain text of the message (any length, lands in payload.body; payload may carry other JSON); threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; alone (no to, no targetEmail) a session id or short id addresses that session; project scopes an account message to sessions declared on it. A request to an account email without project or toSessionRef goes to its one live session, with several it is refused listing them (pick one with toSessionRef). 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. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
|
|
167
222
|
inputSchema: z.object({
|
|
168
223
|
targetEmail: z.string().email().optional(),
|
|
169
224
|
to: z.string().min(3).max(200).optional(),
|
|
@@ -235,8 +290,8 @@ export function notifyExample(kind, to) {
|
|
|
235
290
|
export function notifyDefinitionError(args) {
|
|
236
291
|
const kind = args.kind ?? 'info';
|
|
237
292
|
const example = (text) => `Invalid arguments: ${text} Example: ${notifyExample(kind, args.to)}`;
|
|
238
|
-
if (!args.to && !args.targetEmail)
|
|
239
|
-
return example('name a recipient - `to` (integrator@KEY, an email or a session id) or `
|
|
293
|
+
if (!args.to && !args.targetEmail && !args.toSessionRef?.trim())
|
|
294
|
+
return example('name a recipient - `to` (integrator@KEY, an email or a session id), `targetEmail`, or `toSessionRef` (a session id or its short id).');
|
|
240
295
|
const outcome = args.outcome?.trim() ?? '';
|
|
241
296
|
if ((kind === 'request' || kind === 'complete') && !outcome) {
|
|
242
297
|
return example(`\`outcome\` is required for a ${kind} - the outcome sentence (what must be true when it is done, or what was delivered), ${NOTIFY_OUTCOME_MIN} to ${NOTIFY_OUTCOME_MAX} characters.`);
|
|
@@ -275,7 +330,8 @@ export function makeAgentNotifyHandler(client) {
|
|
|
275
330
|
}
|
|
276
331
|
const who = res.ownerHint?.routed ? res.ownerHint.ownerAddress : args.to ?? args.targetEmail ?? '?';
|
|
277
332
|
const recipients = (res.recipients ?? []).map((r) => r.label);
|
|
278
|
-
const
|
|
333
|
+
const sessionOnly = !res.ownerHint?.routed && !args.to && !args.targetEmail;
|
|
334
|
+
const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${sessionOnly ? '' : `${who} `}session ${res.toSessionId.slice(0, 8)}` : who;
|
|
279
335
|
const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
|
|
280
336
|
const note = res.routingNote ? ` - ${res.routingNote}` : '';
|
|
281
337
|
if (res.deduplicated) {
|
|
@@ -82,7 +82,7 @@ describe('orboto_agent_presence', () => {
|
|
|
82
82
|
expect(text).toContain('2 active session(s)');
|
|
83
83
|
expect(text).toContain('Alice <alice@x.test> (claude-code, instance 00000000-0000-0000-0000-000000000010) - working · working on [ORB] ORB-42');
|
|
84
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');
|
|
85
|
+
expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory&limit=4');
|
|
86
86
|
expect(result.structuredContent.sessions).toHaveLength(2);
|
|
87
87
|
});
|
|
88
88
|
it('handles an empty workspace gracefully', async () => {
|
|
@@ -96,32 +96,56 @@ describe('orboto_agent_presence', () => {
|
|
|
96
96
|
{ userId: '00000000-0000-0000-0000-000000000002', userEmail: 'worker@x.test', userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, autonomyPaused: false, lane: { id: '00000000-0000-0000-0000-000000000040', name: 'worker-1', role: 'implementation', paused: false }, projects: [{ id: '00000000-0000-0000-0000-000000000031', key: 'ACME', name: 'Acme' }, { id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'lane', label: 'worker-1' }, { type: 'live_events', label: 'MCP live events' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000011', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: { name: 'pi' }, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
|
|
97
97
|
{ userId: '00000000-0000-0000-0000-000000000001', userEmail: 'alice@x.test', userFullName: 'Alice', kind: 'agent', isBot: true, actsAs: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [{ type: 'oauth', label: 'claude.ai' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000012', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: {}, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
|
|
98
98
|
];
|
|
99
|
-
it('
|
|
99
|
+
it('ORB-2548: sends every filter to the one presence read, projectKey as project, four rows a page by default', async () => {
|
|
100
100
|
const handler = makeAgentPresenceHandler(client);
|
|
101
|
-
const
|
|
102
|
-
stub([{ json: { sessions: [], inventory:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return result.structuredContent.sessions.map((s) => s.sessionId.slice(-2));
|
|
101
|
+
const url = async (args) => {
|
|
102
|
+
stub([{ json: { sessions: [], inventory: [] } }]);
|
|
103
|
+
await handler(args);
|
|
104
|
+
return String(vi.mocked(fetch).mock.calls.at(-1)?.[0]);
|
|
106
105
|
};
|
|
107
|
-
expect(await
|
|
108
|
-
expect(await
|
|
109
|
-
expect(await
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
106
|
+
expect(await url({})).toBe('https://orboto.example.com/v1/agent/who?include=inventory&limit=4');
|
|
107
|
+
expect(await url({ projectKey: 'acme', kind: 'agent' })).toBe('https://orboto.example.com/v1/agent/who?include=inventory&project=acme&kind=agent&limit=4');
|
|
108
|
+
expect(await url({ email: 'w@x.test', sessionId: 'c785946f', role: 'worker', wake: 'on_call', state: 'live', limit: 2, cursor: 'next-1' }))
|
|
109
|
+
.toBe('https://orboto.example.com/v1/agent/who?include=inventory&role=worker&state=live&email=w%40x.test&sessionId=c785946f&wake=on_call&cursor=next-1&limit=2');
|
|
110
|
+
});
|
|
111
|
+
it('ORB-2548: a projectKey that is unknown or not visible answers in one line instead of a raw 404', async () => {
|
|
112
|
+
stub([{ ok: false, status: 404, json: { errorKey: 'errors.agents.message_project_not_found' } }]);
|
|
113
|
+
const result = await makeAgentPresenceHandler(client)({ projectKey: 'nope' });
|
|
114
|
+
expect(result.isError).toBeUndefined();
|
|
115
|
+
expect(result.content[0].text).toBe('Project "NOPE" is unknown or not visible to you - no session is listed for it.');
|
|
116
|
+
expect(result.structuredContent).toEqual({ sessions: [], nextCursor: null });
|
|
117
|
+
stub([{ ok: false, status: 404, json: {} }]);
|
|
118
|
+
await expect(makeAgentPresenceHandler(client)({ email: 'x@y.test' })).rejects.toThrow();
|
|
119
|
+
});
|
|
120
|
+
it('says when a filter matched nothing', async () => {
|
|
121
|
+
stub([{ json: { sessions: [], inventory: [] } }]);
|
|
116
122
|
const result = await makeAgentPresenceHandler(client)({ projectKey: 'NONE' });
|
|
117
|
-
expect(result.content[0].text).toBe('No active agent
|
|
123
|
+
expect(result.content[0].text).toBe('No active agent session matches the filter.');
|
|
118
124
|
});
|
|
119
|
-
it('renders acts-as, lane, projects and connection types on each line', async () => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
it('renders acts-as, lane, scope, projects and connection types on each line and returns the compact row', async () => {
|
|
126
|
+
const rows = presenceRows().filter((row) => row.kind === 'agent').map((row, i) => (i === 0 ? { ...row, scope: { role: 'worker', projectKeys: ['ACME'] }, wake: 'channel' } : row));
|
|
127
|
+
stub([{ json: { sessions: [], inventory: rows, nextCursor: 'cursor-2' } }]);
|
|
128
|
+
const result = await makeAgentPresenceHandler(client)({ kind: 'agent' });
|
|
129
|
+
const text = result.content[0].text;
|
|
130
|
+
expect(text).toContain('2 active session(s) on this page');
|
|
131
|
+
expect(text).toContain('owner: Alice; lane: worker-1; scope: worker on ACME; projects: ACME, ORB; connections: lane, live_events; reachable by channel');
|
|
124
132
|
expect(text).toContain('owner: acts as Alice; connections: oauth');
|
|
133
|
+
expect(text).toContain('More sessions: call again with cursor "cursor-2"');
|
|
134
|
+
const structured = result.structuredContent;
|
|
135
|
+
expect(structured.nextCursor).toBe('cursor-2');
|
|
136
|
+
expect(structured.sessions[0]).toEqual({
|
|
137
|
+
sessionId: '00000000-0000-0000-0000-000000000011', userEmail: 'worker@x.test', userFullName: null, kind: 'agent', owner: 'Alice',
|
|
138
|
+
status: 'idle', lane: 'worker-1', scope: { role: 'worker', projectKeys: ['ACME'] }, memberProjects: 2, connections: ['lane', 'live_events'],
|
|
139
|
+
wake: 'channel', origin: null, workingOn: null, lastSeenAt: rows[0].lastSeenAt,
|
|
140
|
+
});
|
|
141
|
+
expect(structured.sessions[1]).toMatchObject({ owner: 'acts as Alice', memberProjects: 0, connections: ['oauth'] });
|
|
142
|
+
});
|
|
143
|
+
it('names the member count instead of listing more than eight projects', async () => {
|
|
144
|
+
const many = Array.from({ length: 99 }, (_, i) => ({ id: '00000000-0000-0000-0000-000000000030', key: `P${i}`, name: `Project ${i}` }));
|
|
145
|
+
stub([{ json: { sessions: [], inventory: [{ ...presenceRows()[1], projects: many }] } }]);
|
|
146
|
+
const text = (await makeAgentPresenceHandler(client)({})).content[0].text;
|
|
147
|
+
expect(text).toContain('; member of 99 projects;');
|
|
148
|
+
expect(text).not.toContain('P98');
|
|
125
149
|
});
|
|
126
150
|
});
|
|
127
151
|
describe('orboto_agent_notify', () => {
|
|
@@ -263,6 +287,22 @@ describe('ORB-2136 - session addressing', () => {
|
|
|
263
287
|
expect(result.content[0].text).toContain('bob@example.com session cbb52195');
|
|
264
288
|
expect(result.structuredContent).toMatchObject({ toSessionId: 'cbb52195-0000-4000-8000-000000000000' });
|
|
265
289
|
});
|
|
290
|
+
it('ORB-2548: toSessionRef alone is a recipient - sent without to or targetEmail, the answer names the session', async () => {
|
|
291
|
+
const seen = [];
|
|
292
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
293
|
+
seen.push(JSON.parse(init?.body ?? '{}'));
|
|
294
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: 'cbb52195-0000-4000-8000-000000000000', routedTo: 'session' }), text: async () => '' };
|
|
295
|
+
});
|
|
296
|
+
const result = await makeAgentNotifyHandler(client)({ subject: 'hi', toSessionRef: 'cbb52195' }, { sessionId: 'abc123' });
|
|
297
|
+
expect(result.isError).toBeUndefined();
|
|
298
|
+
expect(seen[0]).toMatchObject({ toSessionRef: 'cbb52195', subject: 'hi' });
|
|
299
|
+
expect(seen[0]).not.toHaveProperty('to');
|
|
300
|
+
expect(result.content[0].text).toContain('notified session cbb52195 (message');
|
|
301
|
+
vi.restoreAllMocks();
|
|
302
|
+
const none = await makeAgentNotifyHandler(client)({ subject: 'hi' });
|
|
303
|
+
expect(none.isError).toBe(true);
|
|
304
|
+
expect(none.content[0].text).toContain('`toSessionRef` (a session id or its short id)');
|
|
305
|
+
});
|
|
266
306
|
it('orboto_messages identifies this session on the fetch and renders sender and receiver identities', async () => {
|
|
267
307
|
const seen = [];
|
|
268
308
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ORB-2502 - orboto_messages lists the newest
|
|
2
|
+
* ORB-2502 - orboto_messages lists the newest 4 unread compactly (10 before ORB-2548) with the exact count; all:true keeps every message in full.
|
|
3
3
|
*/
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { OrbotoClient } from '../orboto-client.js';
|
|
@@ -23,11 +23,11 @@ function stubInbox(urls) {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
describe('ORB-2502 - the compact inbox on MCP', () => {
|
|
26
|
-
it('asks for the newest
|
|
26
|
+
it('asks for the newest 4 compactly, prints the excerpt and the count, and leaves the payload out of the structured answer', async () => {
|
|
27
27
|
const urls = [];
|
|
28
28
|
stubInbox(urls);
|
|
29
29
|
const result = await makeAgentMessagesHandler(client)({ since: '2026-09-30T00:00:00Z' }, { sessionId: 'abc' });
|
|
30
|
-
expect(urls[0]).toContain('limit=
|
|
30
|
+
expect(urls[0]).toContain('limit=4');
|
|
31
31
|
expect(urls[0]).toContain('compact=true');
|
|
32
32
|
expect(urls[0]).toContain('since=2026-09-30T00%3A00%3A00Z');
|
|
33
33
|
const text = result.content[0].text;
|
|
@@ -22,10 +22,12 @@ export function repeatMarks(m) {
|
|
|
22
22
|
].filter(Boolean);
|
|
23
23
|
return marks.length ? ` [${marks.join(', ')}]` : '';
|
|
24
24
|
}
|
|
25
|
+
/** ORB-2548 - the compact page an MCP inbox read returns by default, sized so it fits the 4,000-character budget. */
|
|
26
|
+
export const COMPACT_PAGE_LIMIT = 4;
|
|
25
27
|
const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
|
|
26
28
|
export const agentMessagesToolConfig = {
|
|
27
29
|
title: 'Agent inbox and message work',
|
|
28
|
-
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
|
|
30
|
+
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 4 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. Server-side filters (ORB-2548): direction sent (only your sends, any read state), threadId (that message and its answers), kind; nextCursor continues the page (cursor). 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).',
|
|
29
31
|
inputSchema: z.object({
|
|
30
32
|
messageWork: z.record(z.unknown()).optional()
|
|
31
33
|
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}.'),
|
|
@@ -34,6 +36,10 @@ export const agentMessagesToolConfig = {
|
|
|
34
36
|
since: z.string().optional(),
|
|
35
37
|
project: z.string().min(1).max(64).optional().describe('Key/UUID; includes unscoped mail.'),
|
|
36
38
|
includeOwnSends: z.boolean().default(false),
|
|
39
|
+
direction: z.enum(['received', 'sent']).optional(),
|
|
40
|
+
threadId: z.string().uuid().optional(),
|
|
41
|
+
kind: z.enum(['request', 'error', 'info', 'complete']).optional(),
|
|
42
|
+
cursor: z.string().max(500).optional(),
|
|
37
43
|
ackIds: z.array(z.string().uuid()).max(200).optional(),
|
|
38
44
|
dismiss: z.object({
|
|
39
45
|
ids: z.array(z.string().uuid()).min(1).max(200),
|
|
@@ -66,6 +72,7 @@ export const agentMessagesToolConfig = {
|
|
|
66
72
|
acked: z.number().int(),
|
|
67
73
|
dismissed: z.number().int().optional(),
|
|
68
74
|
closed: z.number().int().optional(),
|
|
75
|
+
nextCursor: z.string().nullable().optional(),
|
|
69
76
|
}).shape,
|
|
70
77
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
71
78
|
};
|
|
@@ -116,28 +123,40 @@ export function makeAgentMessagesHandler(client) {
|
|
|
116
123
|
const q = new URLSearchParams();
|
|
117
124
|
if (args.all)
|
|
118
125
|
q.set('all', 'true');
|
|
119
|
-
q.set('limit', String(args.limit ?? (compact ?
|
|
126
|
+
q.set('limit', String(args.limit ?? (compact ? COMPACT_PAGE_LIMIT : 50)));
|
|
120
127
|
if (compact)
|
|
121
128
|
q.set('compact', 'true');
|
|
122
129
|
if (args.since)
|
|
123
130
|
q.set('since', args.since);
|
|
124
131
|
if (args.project)
|
|
125
132
|
q.set('project', args.project);
|
|
133
|
+
if (args.direction)
|
|
134
|
+
q.set('direction', args.direction);
|
|
135
|
+
if (args.threadId)
|
|
136
|
+
q.set('threadId', args.threadId);
|
|
137
|
+
if (args.kind)
|
|
138
|
+
q.set('kind', args.kind);
|
|
139
|
+
if (args.cursor)
|
|
140
|
+
q.set('cursor', args.cursor);
|
|
126
141
|
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
127
142
|
if (args.includeOwnSends)
|
|
128
143
|
q.set('includeOwn', 'true');
|
|
129
|
-
else
|
|
144
|
+
else if (args.direction !== 'sent')
|
|
130
145
|
q.set('excludeRef', instanceToken);
|
|
131
|
-
const { messages, sessionId, counts } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
|
|
146
|
+
const { messages, sessionId, counts, nextCursor } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
|
|
132
147
|
const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''}${repeatMarks(m)} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
|
|
133
148
|
const lines = messages.length === 0
|
|
134
149
|
? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
|
|
135
150
|
: messages.flatMap((m) => (compact ? compactLines(m, header(m)) : [`${header(m)}${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`]));
|
|
151
|
+
if (messages.length === 0 && args.direction === 'sent')
|
|
152
|
+
lines[0] = 'Nothing sent matches.';
|
|
136
153
|
if (messages.length > 0) {
|
|
137
154
|
lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId (toSessionRef = the sender's instance short id reaches that session only).`);
|
|
138
155
|
}
|
|
139
156
|
if (compact && counts)
|
|
140
157
|
lines.push(`${counts.unread} unread, ${counts.olderThan7Days} older than 7 days (all, limit N, since)`);
|
|
158
|
+
if (nextCursor)
|
|
159
|
+
lines.push(`More messages: call again with cursor "${nextCursor}".`);
|
|
141
160
|
if (args.dismiss) {
|
|
142
161
|
lines.unshift(`Dismissed ${dismissedCount} message(s) as ${args.dismiss.reason}${closedCount > 0 ? `, ${closedCount} closed for the account` : ''}${feedbackSent > 0 ? `, ${feedbackSent} sender(s) told about the misroute` : ''}.`);
|
|
143
162
|
}
|
|
@@ -160,6 +179,7 @@ export function makeAgentMessagesHandler(client) {
|
|
|
160
179
|
acked,
|
|
161
180
|
dismissed: dismissedCount,
|
|
162
181
|
closed: closedCount,
|
|
182
|
+
nextCursor: nextCursor ?? null,
|
|
163
183
|
},
|
|
164
184
|
};
|
|
165
185
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AgentInventoryKindSchema, AgentSessionWakeSchema } from './agent-presence-schema.js';
|
|
3
|
+
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
4
|
+
/** ORB-2548 - one presence row as orboto_agent_presence returns it; the full inventory row is `orboto agents --json` and `GET /v1/agent/who?include=inventory`. */
|
|
5
|
+
export const AgentPresenceRowSchema = z.object({
|
|
6
|
+
sessionId: z.string().uuid(),
|
|
7
|
+
userEmail: z.string(),
|
|
8
|
+
userFullName: z.string().nullable(),
|
|
9
|
+
kind: AgentInventoryKindSchema,
|
|
10
|
+
owner: z.string().nullable(),
|
|
11
|
+
status: z.string(),
|
|
12
|
+
lane: z.string().nullable(),
|
|
13
|
+
scope: AgentSessionScopeSchema.nullable(),
|
|
14
|
+
memberProjects: z.number().int(),
|
|
15
|
+
connections: z.array(z.string()),
|
|
16
|
+
wake: AgentSessionWakeSchema.optional(),
|
|
17
|
+
state: z.string().optional(),
|
|
18
|
+
origin: z.object({ host: z.string().nullable(), repo: z.string().nullable(), pid: z.number().nullable(), harness: z.string().nullable(), client: z.string().nullable() }).nullable(),
|
|
19
|
+
workingOn: z.string().nullable(),
|
|
20
|
+
lastSeenAt: z.string(),
|
|
21
|
+
redacted: z.boolean().optional(),
|
|
22
|
+
});
|
package/dist/tools/agent-who.js
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/** ORB-2264 - the live directory: who a sender can address before sending. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { AgentSessionRoleSchema } from './agent-session-scope.js';
|
|
4
3
|
import { ownerLine } from './responsibilities.js';
|
|
5
|
-
import { originText } from './agent-coordination.js';
|
|
4
|
+
import { nextPageLine, originText, PresenceFilterShape, presenceQuery, readPresence, unknownProjectText } from './agent-coordination.js';
|
|
6
5
|
import { AGENT_WAKE_LABELS } from './agent-presence-schema.js';
|
|
7
6
|
export const agentWhoToolConfig = {
|
|
8
7
|
title: 'Who receives agent mail',
|
|
9
|
-
description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to.
|
|
8
|
+
description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. GET /v1/agent/who; 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); email, sessionId (id or 8+ character prefix), kind and wake narrow on the server; 4 rows a page, nextCursor continues (cursor); a project that is unknown or not visible to you answers so in one line.',
|
|
10
9
|
inputSchema: z.object({
|
|
11
10
|
project: z.string().min(1).max(64).optional(),
|
|
12
|
-
role: AgentSessionRoleSchema.optional(),
|
|
13
11
|
for: z.string().min(2).max(2000).optional(),
|
|
14
|
-
|
|
12
|
+
...PresenceFilterShape,
|
|
15
13
|
}).shape,
|
|
16
|
-
outputSchema: z.object({ sessions: z.array(z.unknown()), owner: z.unknown().optional() }).shape,
|
|
14
|
+
outputSchema: z.object({ sessions: z.array(z.unknown()), owner: z.unknown().optional(), nextCursor: z.string().nullable() }).shape,
|
|
17
15
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
18
16
|
};
|
|
19
17
|
/** One directory row as a line: address, account, session, projects, channel, ticket. */
|
|
@@ -28,25 +26,18 @@ export function whoLine(s) {
|
|
|
28
26
|
}
|
|
29
27
|
export function makeAgentWhoHandler(client) {
|
|
30
28
|
return async (args = {}) => {
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
if (args.project)
|
|
35
|
-
qs.set('project', args.project);
|
|
36
|
-
if (args.role)
|
|
37
|
-
qs.set('role', args.role);
|
|
38
|
-
if (args.for)
|
|
39
|
-
qs.set('for', args.for);
|
|
40
|
-
const res = await client.get(`/v1/agent/who${qs.size ? `?${qs.toString()}` : ''}`);
|
|
29
|
+
const res = await readPresence(client, `/v1/agent/who?${presenceQuery(args)}`, args.project);
|
|
30
|
+
if (!res)
|
|
31
|
+
return { content: [{ type: 'text', text: unknownProjectText(args.project) }], structuredContent: { sessions: [], nextCursor: null } };
|
|
41
32
|
const ownerLines = args.for
|
|
42
33
|
? [res.owner ? `Owner:\n${ownerLine(res.owner)}` : `No entry in the responsibility map matches "${args.for}".`]
|
|
43
34
|
: [];
|
|
44
35
|
const lines = res.sessions.length
|
|
45
|
-
? [...ownerLines, `${res.sessions.length} live session(s):`, ...res.sessions.map(whoLine)]
|
|
36
|
+
? [...ownerLines, `${res.sessions.length} live session(s)${res.nextCursor ? ' on this page' : ''}:`, ...res.sessions.map(whoLine), ...nextPageLine(res.nextCursor)]
|
|
46
37
|
: [...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.'];
|
|
47
38
|
return {
|
|
48
39
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
49
|
-
structuredContent: { sessions: res.sessions, ...(args.for ? { owner: (res.owner ?? null) } : {}) },
|
|
40
|
+
structuredContent: { sessions: res.sessions, ...(args.for ? { owner: (res.owner ?? null) } : {}), nextCursor: res.nextCursor ?? null },
|
|
50
41
|
};
|
|
51
42
|
};
|
|
52
43
|
}
|
|
@@ -17,10 +17,17 @@ describe('orboto_who', () => {
|
|
|
17
17
|
return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [entry] }) };
|
|
18
18
|
});
|
|
19
19
|
const result = await makeAgentWhoHandler(client)({ project: 'ORB', role: 'integrator' });
|
|
20
|
-
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?project=ORB&role=integrator');
|
|
20
|
+
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?project=ORB&role=integrator&limit=4');
|
|
21
21
|
expect(result.content[0].text).toContain('integrator@ORB - claude@orboto.io session f072e1c5, working, claude-code online');
|
|
22
22
|
expect(result.structuredContent).toMatchObject({ sessions: [expect.objectContaining({ shortId: 'f072e1c5' })] });
|
|
23
23
|
});
|
|
24
|
+
it('ORB-2548: a project that is unknown or not visible answers in one line instead of a raw 404', async () => {
|
|
25
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: false, status: 404, statusText: 'Not Found', headers: new Headers(), text: async () => '{"errorKey":"errors.agents.message_project_not_found"}', json: async () => ({}) }));
|
|
26
|
+
const result = await makeAgentWhoHandler(client)({ project: 'gone' });
|
|
27
|
+
expect(result.isError).toBeUndefined();
|
|
28
|
+
expect(result.content[0].text).toBe('Project "GONE" is unknown or not visible to you - no session is listed for it.');
|
|
29
|
+
expect(result.structuredContent).toEqual({ sessions: [], nextCursor: null });
|
|
30
|
+
});
|
|
24
31
|
it('says a request would be refused when nobody matches, and is read-only', async () => {
|
|
25
32
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [] }) }));
|
|
26
33
|
const result = await makeAgentWhoHandler(client)({});
|
|
@@ -41,6 +48,6 @@ describe('orboto_who', () => {
|
|
|
41
48
|
return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [] }) };
|
|
42
49
|
});
|
|
43
50
|
await makeAgentWhoHandler(client)({ state: 'unknown_origin' });
|
|
44
|
-
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?state=unknown_origin');
|
|
51
|
+
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?state=unknown_origin&limit=4');
|
|
45
52
|
});
|
|
46
53
|
});
|
|
@@ -99,14 +99,14 @@ export function makePrimerFactListHandler(client) {
|
|
|
99
99
|
}
|
|
100
100
|
export const primerFactAddToolConfig = {
|
|
101
101
|
title: 'Record a new project primer fact',
|
|
102
|
-
description: 'Record a new structured fact about the project that future AI agents should see at session start. Use when you learn something during work that is missing from the primer (a library version, a convention, a deployment quirk, an architecture decision). Note: the auto-generated primer does NOT automatically include CLAUDE.md / AGENTS.md content unless the operator configured repo briefings and the API host can read those files from disk - most deployments cannot. Treat key conventions documented in CLAUDE.md / AGENTS.md as fair game to record here so they survive cross-deployment. Set observed=true when you are recording from a bot/agent context - the fact will land as agent_observed and require
|
|
102
|
+
description: 'Record a new structured fact about the project that future AI agents should see at session start. Use when you learn something during work that is missing from the primer (a library version, a convention, a deployment quirk, an architecture decision). Note: the auto-generated primer does NOT automatically include CLAUDE.md / AGENTS.md content unless the operator configured repo briefings and the API host can read those files from disk - most deployments cannot. Treat key conventions documented in CLAUDE.md / AGENTS.md as fair game to record here so they survive cross-deployment. Set observed=true when you are recording from a bot/agent context - the fact will land as agent_observed and require a person to verify it before it loses the (observed) marker (CLI: orboto primer-fact add <KEY> --category <c> --key <k> --value "<v>" --observed).'
|
|
103
103
|
+ FACT_WRITING_CONTRACT,
|
|
104
104
|
inputSchema: z.object({
|
|
105
105
|
projectKey: z.string().min(1).describe('Project key, e.g. "ORB". Case-insensitive.'),
|
|
106
106
|
category: PrimerFactCategoryEnum.describe('Category bucket. Pick the closest fit; categories are fixed. Use "non_functional" to capture an explicit non-functional requirement (performance / security / DSGVO / operations / i18n) - the key names the sub-area, the value is the requirement; these render verbatim in the requirements-spec NFR chapter. Use "glossary" for a term/definition pair - these render only in the requirements-spec glossary chapter.'),
|
|
107
107
|
key: z.string().min(1).max(120).describe('Short machine-readable identifier, e.g. "package-manager", "node-version", "css-framework". Lowercase + dashes preferred.'),
|
|
108
108
|
value: z.string().min(1).max(8000).describe('Human-readable Markdown body, 1-3 sentences typically. Include a version number, a path, or a command where relevant.'),
|
|
109
|
-
observed: z.boolean().default(false).describe('True = source is agent_observed (needs operator verification). False = manual entry by operator.
|
|
109
|
+
observed: z.boolean().default(false).describe('True = source is agent_observed (needs operator verification). False = manual entry by operator. A bot account or an agent key is always coerced to agent_observed server-side, whatever is sent.'),
|
|
110
110
|
allowOversize: z.boolean().optional().describe('Override the hard size-cap block (300*2 chars). Only after a call was blocked.'),
|
|
111
111
|
oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this value genuinely needs the length. Audit-logged.'),
|
|
112
112
|
}).shape,
|
|
@@ -151,7 +151,7 @@ export function makePrimerFactAddHandler(client) {
|
|
|
151
151
|
}
|
|
152
152
|
export const primerFactUpdateToolConfig = {
|
|
153
153
|
title: 'Update an existing primer fact',
|
|
154
|
-
description: 'Update an existing fact. Use this when the value has changed (e.g. version bumped) or you are renaming the key. Bumps last_verified_at automatically when the value changes; pure renames leave it untouched. To replace a fact while preserving history, use orboto_primer_fact_supersede instead.'
|
|
154
|
+
description: 'Update an existing fact. Use this when the value has changed (e.g. version bumped) or you are renaming the key. Bumps last_verified_at automatically when the value changes; pure renames leave it untouched. To replace a fact while preserving history, use orboto_primer_fact_supersede instead. A person decides: a bot account or an agent key gets 403 errors.primer_facts.update_needs_person and proposes a corrected fact with orboto_primer_fact_add (observed) instead.'
|
|
155
155
|
+ FACT_WRITING_CONTRACT,
|
|
156
156
|
inputSchema: z.object({
|
|
157
157
|
factId: z.string().min(1).describe('UUID of the fact to update. Read it from orboto_primer_fact_list output.'),
|
|
@@ -206,7 +206,7 @@ export function makePrimerFactUpdateHandler(client) {
|
|
|
206
206
|
}
|
|
207
207
|
export const primerFactSupersedeToolConfig = {
|
|
208
208
|
title: 'Replace a primer fact while preserving history',
|
|
209
|
-
description: 'Replace a fact with a corrected version, preserving the old one in history (linked via superseded_by_id). Use when you discover a previously recorded fact was wrong; do not just delete it. The old row gets a tombstoned key suffix so the partial unique index does not collide; the new row starts unverified and needs
|
|
209
|
+
description: 'Replace a fact with a corrected version, preserving the old one in history (linked via superseded_by_id). Use when you discover a previously recorded fact was wrong; do not just delete it. The old row gets a tombstoned key suffix so the partial unique index does not collide; the new row starts unverified and needs a person to verify it before it loses the (observed) marker. A person decides: a bot account or an agent key gets 403 errors.primer_facts.supersede_needs_person and proposes the corrected fact with orboto_primer_fact_add (observed) instead.'
|
|
210
210
|
+ FACT_WRITING_CONTRACT,
|
|
211
211
|
inputSchema: z.object({
|
|
212
212
|
oldFactId: z.string().min(1).describe('UUID of the fact being replaced.'),
|
|
@@ -247,7 +247,7 @@ export function makePrimerFactSupersedeHandler(client) {
|
|
|
247
247
|
}
|
|
248
248
|
export const primerFactVerifyToolConfig = {
|
|
249
249
|
title: 'Verify an agent-observed primer fact',
|
|
250
|
-
description: 'Promote an agent-observed fact to verified. A person verifies: a bot account or an agent key gets 403 errors.primer_facts.verify_needs_person, so an agent proposes and never confirms. Removes the (observed) marker from the rendered primer and stamps verifiedBy / verifiedAt.',
|
|
250
|
+
description: 'Promote an agent-observed fact to verified. A person verifies: a bot account or an agent key gets 403 errors.primer_facts.verify_needs_person, so an agent proposes and never confirms (CLI: orboto primer-fact verify). Removes the (observed) marker from the rendered primer and stamps verifiedBy / verifiedAt.',
|
|
251
251
|
inputSchema: z.object({
|
|
252
252
|
factId: z.string().min(1).describe('UUID of the fact to verify.'),
|
|
253
253
|
}).shape,
|
|
@@ -274,7 +274,7 @@ export function makePrimerFactVerifyHandler(client) {
|
|
|
274
274
|
}
|
|
275
275
|
export const primerFactDeleteToolConfig = {
|
|
276
276
|
title: 'Delete a primer fact',
|
|
277
|
-
description: 'Hard-delete a fact. Prefer orboto_primer_fact_supersede when the fact is being replaced; only delete when the fact was wrong from the start or no longer relevant. Writes an audit-log entry; the row itself is gone (no soft-delete).',
|
|
277
|
+
description: 'Hard-delete a fact. Prefer orboto_primer_fact_supersede when the fact is being replaced; only delete when the fact was wrong from the start or no longer relevant. Writes an audit-log entry; the row itself is gone (no soft-delete). Deleting an unverified agent-observed fact is the Reject of the review queue. A person decides: a bot account or an agent key gets 403 errors.primer_facts.reject_needs_person (observed proposal) or errors.primer_facts.delete_needs_person (CLI: orboto primer-fact reject).',
|
|
278
278
|
inputSchema: z.object({
|
|
279
279
|
factId: z.string().min(1).describe('UUID of the fact to delete.'),
|
|
280
280
|
reason: z.string().max(500).optional().describe('Optional human-readable reason; recorded in the audit log details for future operators.'),
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2548 - the evidence cases of the OCP integrator (2026-10-16) answered
|
|
3
|
+
* inside the 4,000-character budget: presence returned 236,455 characters
|
|
4
|
+
* with 1 of 32 rows kept, and finding one session took ~230 kB of expand
|
|
5
|
+
* calls; the inbox needed one to three expand calls.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
9
|
+
import { DEFAULT_BUDGET_CHARS, applyResponseBudget, budgetFor, measureHalves } from '../response-budget.js';
|
|
10
|
+
import { makeAgentPresenceHandler } from './agent-coordination.js';
|
|
11
|
+
import { makeAgentWhoHandler } from './agent-who.js';
|
|
12
|
+
import { makeAgentMessagesHandler } from './agent-messages.js';
|
|
13
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
14
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
15
|
+
const uuid = (n, tail = 0) => `${String(n).padStart(8, '0')}-0000-4000-8000-${String(tail).padStart(12, '0')}`;
|
|
16
|
+
/** One inventory row in the shape the evidence measured: a bot account that is a member of 80 projects (~10 kB, the live rows measured 1.7 to 10.3 kB). */
|
|
17
|
+
function inventoryRow(i) {
|
|
18
|
+
const account = Math.floor(i / 4);
|
|
19
|
+
return {
|
|
20
|
+
userId: uuid(account, 1), userEmail: `agent-${account}-f82fd0ea@agents.internal`, userFullName: `Agent ${account}`, kind: 'agent', isBot: true,
|
|
21
|
+
actsAs: null, owner: { id: uuid(900, 2), name: 'Operator Person', email: 'operator@example.test' }, autonomyPaused: false,
|
|
22
|
+
sessionId: uuid(i, 3), status: 'idle', lane: null,
|
|
23
|
+
projects: Array.from({ length: 80 }, (_, p) => ({ id: uuid(p, 4), key: `P${String(p).padStart(3, '0')}`, name: `Project number ${p} - long name` })),
|
|
24
|
+
connections: [{ type: 'api_key', label: 'agent key for the build host' }, { type: 'channel', label: 'claude-code wake channel' }],
|
|
25
|
+
wake: 'channel', workingOnTicket: { id: uuid(i, 5), key: `ORB-${2500 + i}`, title: 'A ticket title of ordinary length for a working session', projectKey: 'ORB' },
|
|
26
|
+
workSessions: [{ ticketId: uuid(i, 5), ticketKey: `ORB-${2500 + i}`, role: 'implementation', leaseUntil: '2026-10-16T10:15:00.000Z', resourceClaims: [] }],
|
|
27
|
+
capabilities: ['writes-tickets', 'reviews'],
|
|
28
|
+
clientInfo: { name: 'claude-code', host: 'build-host.fritz.box', channel: 'claude-code', credential: 'api_key', credentialId: uuid(i, 6), credentialActor: 'agent', channelStart: 'orboto-claude', contextReportedAt: '2026-10-16T09:51:55.631Z' },
|
|
29
|
+
scope: { projectKeys: ['OCP'], role: i === 17 ? 'integrator' : 'worker' },
|
|
30
|
+
unreadMessages: 3, wakes: { count: 4, precision: 1, wrongWakeContextTokens: 0, backlogSeen: 0 },
|
|
31
|
+
guard: { held: 0, released: 2, stalled: 0, consecutiveHolds: 0, lastOutcome: 'released', lastReason: null, lastAt: '2026-10-16T09:50:00.000Z', paused: false },
|
|
32
|
+
knowledge: { writes: 0, asks: 1, lastAt: null, localPending: false },
|
|
33
|
+
restartPending: false, restartReason: null, restartable: true, reconnects: 2, lastReconnectAt: '2026-10-16T09:40:00.000Z',
|
|
34
|
+
lastSeenAt: '2026-10-16 09:54:00.504+00', createdAt: '2026-10-16 08:01:12.719715+00',
|
|
35
|
+
origin: { host: 'build-host', machineId: '4ea7904991187bf1', repo: 'orboto-control-plane', pid: 15503 + i, startedAt: '2026-10-16T08:00:00.000Z', client: 'orboto-mcp', clientVersion: '0.224.0', harness: 'claude-code', credential: 'api_key', known: true },
|
|
36
|
+
state: 'live', activity: 'live', touchedAt: '2026-10-16 09:54:00.504+00',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const WORKSPACE = Array.from({ length: 32 }, (_, i) => inventoryRow(i));
|
|
40
|
+
/** The presence read as the API answers it: the ORB-2548 filters and the page on the server (the API side is tested in apps/api). */
|
|
41
|
+
function servePresence() {
|
|
42
|
+
const urls = [];
|
|
43
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
44
|
+
const url = new URL(String(input));
|
|
45
|
+
urls.push(url.pathname + url.search);
|
|
46
|
+
const q = url.searchParams;
|
|
47
|
+
const rows = WORKSPACE.filter((r) => (!q.get('sessionId') || r.sessionId.startsWith(q.get('sessionId')))
|
|
48
|
+
&& (!q.get('email') || r.userEmail === q.get('email'))
|
|
49
|
+
&& (!q.get('role') || r.scope.role === q.get('role')));
|
|
50
|
+
const start = q.get('cursor') ? Number(q.get('cursor')) : 0;
|
|
51
|
+
const limit = Number(q.get('limit') ?? rows.length);
|
|
52
|
+
const page = rows.slice(start, start + limit);
|
|
53
|
+
const nextCursor = start + limit < rows.length ? String(start + limit) : null;
|
|
54
|
+
const sessions = page.map((r) => ({
|
|
55
|
+
sessionId: r.sessionId, shortId: r.sessionId.slice(0, 8), userId: r.userId, email: r.userEmail, isBot: true, role: r.scope.role,
|
|
56
|
+
projectKeys: r.scope.projectKeys, ticketKeys: [], addresses: [`${r.scope.role}@OCP`], lastSeenAt: r.lastSeenAt,
|
|
57
|
+
channel: { announced: 'claude-code', online: true }, wake: r.wake, currentTicket: { key: r.workingOnTicket.key, title: r.workingOnTicket.title },
|
|
58
|
+
status: r.status, host: 'build-host.fritz.box', origin: r.origin, state: r.state, activity: r.activity, createdAt: r.createdAt,
|
|
59
|
+
}));
|
|
60
|
+
const body = q.get('include') === 'inventory' ? { sessions, inventory: page, nextCursor } : { sessions, nextCursor };
|
|
61
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => JSON.stringify(body) };
|
|
62
|
+
});
|
|
63
|
+
return urls;
|
|
64
|
+
}
|
|
65
|
+
function underBudget(tool, result) {
|
|
66
|
+
const halves = measureHalves(result);
|
|
67
|
+
const outcome = applyResponseBudget(tool, result, {});
|
|
68
|
+
return { ...halves, truncated: outcome.truncatedChars, budget: budgetFor(tool, {}) };
|
|
69
|
+
}
|
|
70
|
+
describe('ORB-2548 - presence reads fit the MCP response budget', () => {
|
|
71
|
+
it('reproduces the evidence: the unfiltered inventory of 32 sessions is over 230 kB, far over the 4,000 budget', () => {
|
|
72
|
+
const all = JSON.stringify({ sessions: WORKSPACE }).length;
|
|
73
|
+
expect(all).toBeGreaterThan(230_000);
|
|
74
|
+
expect(budgetFor('orboto_agent_presence', {})).toBe(DEFAULT_BUDGET_CHARS);
|
|
75
|
+
});
|
|
76
|
+
it('one session by its short id comes back whole, under budget, without an expand handle', async () => {
|
|
77
|
+
const urls = servePresence();
|
|
78
|
+
const result = await makeAgentPresenceHandler(client)({ sessionId: uuid(17, 3).slice(0, 8) });
|
|
79
|
+
const m = underBudget('orboto_agent_presence', result);
|
|
80
|
+
expect(urls[0]).toBe(`/v1/agent/who?include=inventory&sessionId=${uuid(17, 3).slice(0, 8)}&limit=4`);
|
|
81
|
+
expect(m.truncated).toBe(0);
|
|
82
|
+
expect(m.structuredChars).toBeLessThan(DEFAULT_BUDGET_CHARS);
|
|
83
|
+
expect(m.textChars).toBeLessThan(DEFAULT_BUDGET_CHARS);
|
|
84
|
+
const { sessions } = result.structuredContent;
|
|
85
|
+
expect(sessions).toEqual([expect.objectContaining({ sessionId: uuid(17, 3), scope: { projectKeys: ['OCP'], role: 'integrator' } })]);
|
|
86
|
+
expect(result.content[0].text).toContain(`instance ${uuid(17, 3)}`);
|
|
87
|
+
});
|
|
88
|
+
it('an account email with four sessions, and the unfiltered first page of four, stay under budget with nextCursor for the rest', async () => {
|
|
89
|
+
servePresence();
|
|
90
|
+
const byEmail = await makeAgentPresenceHandler(client)({ email: 'agent-2-f82fd0ea@agents.internal' });
|
|
91
|
+
expect(byEmail.structuredContent.sessions).toHaveLength(4);
|
|
92
|
+
expect(underBudget('orboto_agent_presence', byEmail)).toMatchObject({ truncated: 0 });
|
|
93
|
+
const first = await makeAgentPresenceHandler(client)({});
|
|
94
|
+
const m = underBudget('orboto_agent_presence', first);
|
|
95
|
+
expect(m.truncated).toBe(0);
|
|
96
|
+
expect(Math.max(m.textChars, m.structuredChars)).toBeLessThan(DEFAULT_BUDGET_CHARS);
|
|
97
|
+
expect(first.structuredContent.nextCursor).toBe('4');
|
|
98
|
+
expect(first.content[0].text).toContain('call again with cursor "4"');
|
|
99
|
+
});
|
|
100
|
+
it('orboto_who answers the same filtered set under budget', async () => {
|
|
101
|
+
const urls = servePresence();
|
|
102
|
+
const result = await makeAgentWhoHandler(client)({ role: 'integrator' });
|
|
103
|
+
expect(urls[0]).toBe('/v1/agent/who?role=integrator&limit=4');
|
|
104
|
+
expect(result.structuredContent.sessions.map((s) => s.sessionId)).toEqual([uuid(17, 3)]);
|
|
105
|
+
expect(underBudget('orboto_who', result)).toMatchObject({ truncated: 0 });
|
|
106
|
+
const page = await makeAgentWhoHandler(client)({});
|
|
107
|
+
expect(underBudget('orboto_who', page)).toMatchObject({ truncated: 0 });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
const party = (n, role) => ({ userId: uuid(n, 7), email: `agent-${n}@orboto.io`, sessionId: uuid(n, 8), role, label: `agent-${n}@orboto.io (${role}, ${uuid(n, 8).slice(0, 8)})` });
|
|
111
|
+
/** An inbox message in the measured live shape: a 182-character subject, a 1 kB payload, the compact excerpt. */
|
|
112
|
+
function message(i, threadId) {
|
|
113
|
+
const body = `${'Status of the rollout on the tenant hosts, with the commands that ran and their exit codes. '.repeat(11)}`;
|
|
114
|
+
return {
|
|
115
|
+
id: uuid(100 + i, 9), fromUserId: uuid(1, 7), toUserId: uuid(2, 7), fromSessionId: uuid(1, 8), toSessionId: null,
|
|
116
|
+
from: party(1, 'integrator'), to: party(2, 'worker'), kind: i % 2 ? 'complete' : 'request',
|
|
117
|
+
subject: `OCP-${800 + i}: ${'rollout of the verified release to every tenant host, the backup first and core before the tenants, '.repeat(2).slice(0, 172)}`,
|
|
118
|
+
payload: { body }, threadId, projectKey: 'OCP', createdAt: `2026-10-16 09:${String(10 + i).padStart(2, '0')}:00.504123+00`,
|
|
119
|
+
deliveredAt: null, readAt: null, dismissed: null, excerpt: `body: ${body.slice(0, 294)}`, payloadChars: body.length + 6,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function serveInbox(messages) {
|
|
123
|
+
const urls = [];
|
|
124
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
125
|
+
const url = new URL(String(input));
|
|
126
|
+
urls.push(url.pathname + url.search);
|
|
127
|
+
const q = url.searchParams;
|
|
128
|
+
const rows = messages.filter((m) => (!q.get('threadId') || m.id === q.get('threadId') || m.threadId === q.get('threadId')) && (!q.get('kind') || m.kind === q.get('kind')));
|
|
129
|
+
const limit = Number(q.get('limit'));
|
|
130
|
+
const body = { messages: rows.slice(0, limit), sessionId: uuid(2, 8), counts: { unread: rows.length, olderThan7Days: 0 }, nextCursor: rows.length > limit ? 'next-page' : null };
|
|
131
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => JSON.stringify(body) };
|
|
132
|
+
});
|
|
133
|
+
return urls;
|
|
134
|
+
}
|
|
135
|
+
describe('ORB-2548 - inbox reads fit the MCP response budget', () => {
|
|
136
|
+
const inbox = [...Array.from({ length: 12 }, (_, i) => message(i, null)), ...Array.from({ length: 4 }, (_, i) => message(20 + i, uuid(100, 9)))];
|
|
137
|
+
it('the default compact page (4) of full-size messages fits; the old default of 10 did not', async () => {
|
|
138
|
+
serveInbox(inbox);
|
|
139
|
+
const page = await makeAgentMessagesHandler(client)({});
|
|
140
|
+
const m = underBudget('orboto_messages', page);
|
|
141
|
+
expect(m.truncated).toBe(0);
|
|
142
|
+
expect(Math.max(m.textChars, m.structuredChars)).toBeLessThan(DEFAULT_BUDGET_CHARS);
|
|
143
|
+
expect(page.structuredContent.nextCursor).toBe('next-page');
|
|
144
|
+
serveInbox(inbox);
|
|
145
|
+
const old = await makeAgentMessagesHandler(client)({ limit: 10 });
|
|
146
|
+
expect(underBudget('orboto_messages', old).truncated).toBeGreaterThan(0);
|
|
147
|
+
});
|
|
148
|
+
it('one thread and one kind are filtered on the server and come back under budget', async () => {
|
|
149
|
+
const urls = serveInbox(inbox);
|
|
150
|
+
const thread = await makeAgentMessagesHandler(client)({ threadId: uuid(100, 9) });
|
|
151
|
+
expect(urls[0]).toContain(`threadId=${uuid(100, 9)}`);
|
|
152
|
+
expect(thread.structuredContent).toMatchObject({ nextCursor: 'next-page' });
|
|
153
|
+
expect(thread.structuredContent.messages).toHaveLength(4);
|
|
154
|
+
expect(underBudget('orboto_messages', thread)).toMatchObject({ truncated: 0 });
|
|
155
|
+
const sent = await makeAgentMessagesHandler(client)({ direction: 'sent', kind: 'request', limit: 3 });
|
|
156
|
+
expect(urls[1]).toContain('direction=sent');
|
|
157
|
+
expect(urls[1]).toContain('kind=request');
|
|
158
|
+
expect(urls[1]).not.toContain('excludeRef');
|
|
159
|
+
expect(underBudget('orboto_messages', sent)).toMatchObject({ truncated: 0 });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -51,7 +51,7 @@ describe('orboto_who for', () => {
|
|
|
51
51
|
const urls = [];
|
|
52
52
|
respond(200, { sessions: [], owner: match }, urls);
|
|
53
53
|
const result = await makeAgentWhoHandler(client)({ for: 'unity asset' });
|
|
54
|
-
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?for=unity+asset');
|
|
54
|
+
expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?for=unity+asset&limit=4');
|
|
55
55
|
const text = result.content[0].text;
|
|
56
56
|
expect(text).toContain('Owner:\n- worker@GAMES');
|
|
57
57
|
expect(text).toContain('No live session answers to worker@GAMES - send with queue: true and project GAMES');
|
|
@@ -189,7 +189,7 @@ describe('orboto_session_start - rules-hash ack (ORB-1607, ORB-2576)', () => {
|
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
191
|
describe('orboto_session_start - knowledge layers and project facts (ORB-2575)', () => {
|
|
192
|
-
const FACTS_TEXT = '## Where knowledge lives\n- Rules: binding behaviour.\n\n## Project facts\n### ORB
|
|
192
|
+
const FACTS_TEXT = '## Where knowledge lives\n- Rules: binding behaviour.\n\n## Project facts\n### ORB: 1 of 3 facts\n- tech_stack/stack: Go and Postgres\n2 more: `orboto primer ORB`';
|
|
193
193
|
const facts = (text) => ({
|
|
194
194
|
source: 'session', unknown: [], text,
|
|
195
195
|
projects: [{ projectKey: 'ORB', projectId: 'proj-1', total: 3, facts: [{ category: 'tech_stack', key: 'stack', value: 'Go and Postgres', observed: false }] }],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.227.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",
|