Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions .github/workflows/bot-harness-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,11 @@ on:
- "packages/openclaw/openclaw.plugin.json"
- "packages/openclaw/tsconfig.json"
- "packages/openclaw/scripts/**"
- "packages/hermes-tlon-adapter/__init__.py"
- "packages/hermes-tlon-adapter/adapter.py"
- "packages/hermes-tlon-adapter/approval.py"
- "packages/hermes-tlon-adapter/attention.py"
- "packages/hermes-tlon-adapter/channel_access.py"
- "packages/hermes-tlon-adapter/history.py"
- "packages/hermes-tlon-adapter/image_search.py"
- "packages/hermes-tlon-adapter/mention.py"
- "packages/hermes-tlon-adapter/owner_listen.py"
- "packages/hermes-tlon-adapter/presence.py"
- "packages/hermes-tlon-adapter/telemetry.py"
- "packages/hermes-tlon-adapter/tlon_api.py"
- "packages/hermes-tlon-adapter/tlon_tool.py"
- "packages/hermes-tlon-adapter/version.py"
# Every top-level runtime module, minus the test suite. A per-file list
# silently stopped deploying cite/lens/media/migration/nudge/sanitize as
# they were added; `*` does not match `/`, so dev/ and prompts/ stay out.
- "packages/hermes-tlon-adapter/*.py"
- "!packages/hermes-tlon-adapter/test_*.py"
- "packages/hermes-tlon-adapter/plugin.yaml"
- "packages/hermes-tlon-adapter/prompts/**"
- "packages/hermes-tlon-adapter/requirements.txt"
Expand Down
123 changes: 123 additions & 0 deletions docs/bot-command-manifests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Bot command manifests

Bots advertise the slash commands they implement in their own contact profile. The Tlon client reads the manifest off the synced contact record and uses it to drive the slash-command popup for bot conversations; bots that do not advertise a manifest fall back to a static list. The manifest is the bot's property, not the client binary's — a command change ships as a bot deploy, not an app release.

No Hoon/desk changes are involved: a v1 contact is an open key-value map (`+$ contact (map @tas value)`, `desk/sur/contacts.hoon`), unknown keys pass validation and replicate to subscribers, and the client's contacts pipeline carries the key through.

## Wire format

- Contact key: `bot-commands` (`@tas`-safe).
- Value: a `%text` contact field whose text is JSON.
- The manifest is self-published by the bot ship via a `%self` contact action (a merge — nickname/avatar/other keys survive), and propagates to peers through ordinary contact sync (`/v1/news` `%peer`/`%page` facts, `/v1/book`, `/v1/contact/{ship}`).

```json
{ "type": "text", "value": "{\"v\":1,\"commands\":[...]}" }
```

## JSON schema

```json
{
"v": 1,
"commands": [
{
"command": "/allow",
"title": "Allow",
"subtitle": "Approve a pending request",
"icon": "Checkmark",
"keywords": ["approve"],
"insertText": "/allow "
}
]
}
```

| field | type | notes |
| ----------------------- | ------------------- | ----------------------------------------------------------------------------------------- |
| `v` | `number` | Format version. Anything other than `1` rejects the whole manifest (static fallback). |
| `commands` | `array` | **Array order is the ranking priority** — the client assigns `priority = index + 1`. |
| `commands[].command` | `string` (required) | Must match `^\/[a-zA-Z0-9-]{1,32}$` or it can never trigger the popup. |
| `commands[].title` | `string` (required) | ≤ 64 chars. |
| `commands[].subtitle` | `string` (optional) | ≤ 160 chars. |
| `commands[].icon` | `string` (optional) | Icon name, ≤ 32 chars. Unknown names degrade to the generic command icon. |
| `commands[].keywords` | `string[]` (optional)| ≤ 8 entries, each ≤ 32 chars. |
| `commands[].insertText` | `string` (optional) | ≤ 128 chars. Defaults to `"<command> "` when omitted. |

### Icons

`icon` is the **name** of a glyph in the client's built-in icon set
(`packages/ui/src/assets/icons`) — not a URL and not an image. The client
resolves the name to a bundled glyph; a name it does not recognise, or an
absent `icon`, renders the generic command glyph instead. An unknown name is
never an error and never blocks the entry.

Names used by the two in-repo runtimes, as a starting set:

| icon | used for |
| ----------- | --------------------------------- |
| `Checkmark` | `/allow` |
| `Close` | `/reject` |
| `EyeClosed` | `/ban`, `/banned` |
| `EyeOpen` | `/unban` |
| `Clock` | `/pending` |
| `Command` | `/owner-listen` |
| `Info` | `/tlon`, `/tlon-version` |
| `Copy` | `/migrate` |
| `Lock` | `/channel-access` |

Unknown fields are ignored at every level (forward compatibility). Invalid entries are skipped; duplicate `command` tokens keep the first occurrence. A manifest with zero valid entries is treated as absent.

## Caps

Client-enforced at parse time (in one pure function, `parseBotCommandManifest` in `packages/shared/src/domain/slashCommands.ts`):

- Raw manifest: ≤ **6,000 UTF-8 bytes** (`new TextEncoder().encode(raw).byteLength` in TS, `len(raw.encode('utf-8'))` in Python).
- ≤ 32 entries (first 32 kept), plus the per-field caps in the table above.

These are manifest-local ceilings only. The backend additionally caps the **whole jammed profile** (bio, groups, attestations, manifest included) at 10 kB (`desk/lib/contacts.hoon`), so publishers must treat a rejected poke as a real, non-fatal outcome — the bot keeps working and clients fall back to the static list.

## Publishing

Compare-then-poke at boot: compute the manifest JSON, read the current self-contact (`/contacts/v1/self`), and poke only when the value differs. Byte-stable serialization (fixed key order) so the comparison does not false-positive. Example poke, via Eyre or any existing poke path:

```json
{
"app": "contacts",
"mark": "contact-action-1",
"json": {
"self": {
"bot-commands": {
"type": "text",
"value": "{\"v\":1,\"commands\":[{\"command\":\"/allow\",\"title\":\"Allow\"}]}"
}
}
}
}
```

## Clearing the key (rollback / retirement)

`%self` is a merge: contact keys die only by explicit `null`. Reverting or downgrading a runtime, or retiring a bot, leaves the last-published manifest in the profile until it is cleared. Poke:

```json
{
"app": "contacts",
"mark": "contact-action-1",
"json": { "self": { "bot-commands": null } }
}
```

runnable via each runtime's existing poke path or curl against Eyre. Clients then fall back to the static list. A runtime _switch_ needs no clearing — the new runtime's differing manifest overwrites on first boot.

## Client consumption

- The raw JSON is stored on the contact row (`contacts.bot_commands`) and validated only at read.
- `useBotSlashCommandManifest` (`packages/shared/src/store/useBotSlashCommandManifest.ts`) resolves the bot ship for DM channels, prefers the advertised manifest, and falls back to the static OpenClaw list when absent/invalid. Gating (which conversations get a popup at all) is unchanged.
- Cold-start backfill: the legacy v0 `/all` peers scry strips namespaced keys, so the client fetches `/v1/contact/{ship}` on demand for qualifying bot channels that lack a manifest.

## Runtime implementations

- OpenClaw plugin: registry + publisher in `packages/openclaw/src/commands-registry.ts` / `src/bot-command-manifest.ts`; fixture `packages/openclaw/fixtures/command-manifest.json`. Advertises its 10 plugin commands; OpenClaw _core_ commands (`/status`, `/help`, `/new`) are not advertised because the core's builtin command registry is not exported from the pinned `openclaw` package and cannot be parity-asserted in CI.
- Hermes adapter: registry + publisher in `packages/hermes-tlon-adapter/commands.py` / `adapter.py`; fixture `packages/hermes-tlon-adapter/fixtures/command-manifest.json`. Advertises its 10 adapter control commands; `/tlon-version` is handled but hidden (`advertise=False`, reason: legacy alias of `/tlon version`). Hermes _core_ chat commands are never advertised (not verifiable from this repo).

Both fixtures are parity-tested against their runtime's actual registrations and parsed client-side in CI, so an advertised manifest can only list commands the bot actually handles.
177 changes: 176 additions & 1 deletion packages/api/src/__tests__/contactsApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import { expect, test } from 'vitest';
import { describe, expect, test, vi } from 'vitest';

import {
type ContactsUpdate,
contactToClientProfile,
extractBotCommandsValue,
getContactProfile,
subscribeToContactUpdates,
v0PeerToClientProfile,
v0PeersToClientProfiles,
v1PeerToClientProfile,
} from '../client/contactsApi';
import { scry, subscribe } from '../client/urbit';
import type { ContactBookProfile } from '../urbit/contact';

vi.mock('../client/urbit', async () => {
const actual =
await vi.importActual<typeof import('../client/urbit')>('../client/urbit');
return {
...actual,
scry: vi.fn(),
subscribe: vi.fn(),
};
});

const scryMock = scry as unknown as ReturnType<typeof vi.fn>;
const subscribeMock = subscribe as unknown as ReturnType<typeof vi.fn>;

const inputContact: [string, any] = [
'test',
Expand Down Expand Up @@ -52,3 +73,157 @@ test('converts an array of contacts from server to client format', () => {
v0PeersToClientProfiles({ [inputContact[0]]: inputContact[1] })
).toStrictEqual([outputContact]);
});

describe('bot-commands contact field', () => {
const manifestJson = JSON.stringify({
v: 1,
commands: [{ command: '/allow', title: 'Allow' }],
});

test('v1 peer mapper carries a well-formed text field', () => {
const contact = v1PeerToClientProfile('~bot', {
nickname: { type: 'text', value: 'Bot' },
'bot-commands': { type: 'text', value: manifestJson },
});
expect(contact.botCommands).toBe(manifestJson);
});

test('v1 peer mapper clears (null) when the field is absent', () => {
const contact = v1PeerToClientProfile('~bot', {
nickname: { type: 'text', value: 'Bot' },
});
expect(contact.botCommands).toBeNull();
});

test.each([
['set field', { type: 'set', value: [] }],
['numb field', { type: 'numb', value: '0x1' }],
['look field', { type: 'look', value: 'https://example.com' }],
['text field with non-string value', { type: 'text', value: 42 }],
['text field missing value', { type: 'text' }],
['bare string', manifestJson],
['array', [{ type: 'text', value: manifestJson }]],
['null', null],
])('v1 peer mapper rejects wrong shape: %s', (_label, field) => {
const contact = v1PeerToClientProfile('~bot', {
'bot-commands': field,
} as unknown as ContactBookProfile);
expect(contact.botCommands).toBeNull();
});

test('book mapper reads the base contact, not the mod overlay', () => {
const contact = contactToClientProfile('~bot', [
{ 'bot-commands': { type: 'text', value: manifestJson } },
{ 'bot-commands': { type: 'text', value: '{"v":1,"commands":[]}' } },
]);
expect(contact.botCommands).toBe(manifestJson);
});

test('book mapper carries the base field when there is no overlay', () => {
const contact = contactToClientProfile('~bot', [
{ 'bot-commands': { type: 'text', value: manifestJson } },
null,
]);
expect(contact.botCommands).toBe(manifestJson);
});

test('book mapper ignores a manifest that only exists in the overlay', () => {
const contact = contactToClientProfile('~bot', [
{},
{ 'bot-commands': { type: 'text', value: manifestJson } },
]);
expect(contact.botCommands).toBeNull();
});

test('extractBotCommandsValue accepts only text-shaped fields', () => {
expect(extractBotCommandsValue({ type: 'text', value: manifestJson })).toBe(
manifestJson
);
expect(extractBotCommandsValue(undefined)).toBeNull();
expect(extractBotCommandsValue({ type: 'text', value: null })).toBeNull();
expect(extractBotCommandsValue({ value: manifestJson })).toBeNull();
});
});

// The two carriers that recover an advertised manifest after the lossy v0
// `/all` sync: the live `/v1/news` subscription and the targeted v1 scry.
describe('bot-commands sync carriers', () => {
const manifest = JSON.stringify({
v: 1,
commands: [{ command: '/allow', title: 'Allow' }],
});

function capturedNewsHandler() {
const updates: ContactsUpdate[] = [];
subscribeMock.mockClear();
subscribeToContactUpdates((update) => updates.push(update));
const [params, onEvent] = subscribeMock.mock.calls[0];
expect(params).toEqual({ app: 'contacts', path: '/v1/news' });
return { updates, onEvent: onEvent as (event: unknown) => void };
}

test('a %peer fact carries the manifest through the subscription', () => {
const { updates, onEvent } = capturedNewsHandler();

onEvent({
peer: {
who: '~bot',
contact: { 'bot-commands': { type: 'text', value: manifest } },
},
});

expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({
type: 'upsertContact',
contact: { id: '~bot', botCommands: manifest },
});
});

test('a %page fact carries the manifest from the base contact', () => {
const { updates, onEvent } = capturedNewsHandler();

onEvent({
page: {
kip: '~bot',
contact: { 'bot-commands': { type: 'text', value: manifest } },
mod: null,
},
});

expect(updates[0]).toMatchObject({
type: 'upsertContact',
contact: { id: '~bot', botCommands: manifest },
});
});

test('a fact without the key clears the stored manifest', () => {
const { updates, onEvent } = capturedNewsHandler();

onEvent({ peer: { who: '~bot', contact: { nickname: 'Bot' } } });

expect(updates[0]).toMatchObject({
type: 'upsertContact',
contact: { id: '~bot', botCommands: null },
});
});

test('getContactProfile scries the un-suffixed v1 contact path', async () => {
scryMock.mockResolvedValueOnce({
'bot-commands': { type: 'text', value: manifest },
});

const contact = await getContactProfile('~bot');

// No `.json` — the transport appends it; a suffixed path 404s.
expect(scryMock).toHaveBeenCalledWith({
app: 'contacts',
path: '/v1/contact/~bot',
});
expect(contact?.botCommands).toBe(manifest);
});

test('getContactProfile returns null when the scry fails', async () => {
scryMock.mockRejectedValueOnce(new Error('404'));
expect(await getContactProfile('~bot')).toBeNull();
});
});
Loading
Loading