herdrchat 0.1.0 → 1.1.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/README.md +1 -0
- package/README.zh.md +1 -0
- package/bin/cli.mjs +64 -15
- package/bin/herdrchat.mjs +63 -1246
- package/config.env.example +14 -0
- package/docs/architecture.md +76 -0
- package/docs/guide.md +18 -6
- package/lib/chats/contract.mjs +58 -0
- package/lib/{feishu.mjs → chats/feishu/client.mjs} +23 -2
- package/lib/chats/feishu/index.mjs +499 -0
- package/lib/{overview.mjs → chats/feishu/overview.mjs} +2 -2
- package/lib/chats/index.mjs +20 -0
- package/lib/chats/telegram/client.mjs +83 -0
- package/lib/chats/telegram/format.mjs +83 -0
- package/lib/chats/telegram/index.mjs +411 -0
- package/lib/{config.mjs → core/config.mjs} +39 -3
- package/lib/core/engine.mjs +1041 -0
- package/lib/{hc.mjs → core/hc.mjs} +50 -2
- package/lib/{log.mjs → core/log.mjs} +6 -3
- package/lib/{readers → core/readers}/claude.mjs +1 -0
- package/lib/core/readers/index.mjs +49 -0
- package/lib/core/views.mjs +58 -0
- package/package.json +6 -1
- package/skills/herdrchat/SKILL.md +1 -1
- package/skills/herdrchat/references/ops.md +15 -2
- package/skills/herdrchat/references/telegram.md +23 -0
- package/lib/readers/index.mjs +0 -24
- /package/lib/{avatar.mjs → chats/feishu/avatar.mjs} +0 -0
- /package/lib/{userauth.mjs → chats/feishu/userauth.mjs} +0 -0
- /package/lib/{cursor.mjs → core/cursor.mjs} +0 -0
- /package/lib/{events.mjs → core/events.mjs} +0 -0
- /package/lib/{home.mjs → core/home.mjs} +0 -0
- /package/lib/{menu.mjs → core/menu.mjs} +0 -0
package/config.env.example
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# herdrchat settings, the example of config.env. `herdrchat init` copies it to where the
|
|
2
2
|
# profile reads it (~/.config/herdrchat/default/config.env, ~/.config/herdrchat/<profile>/
|
|
3
3
|
# with --profile, or HERDRCHAT_HOME), private; then fill it in.
|
|
4
|
+
|
|
5
|
+
# the chat app this profile mirrors into: feishu (default) or telegram. To use both, run
|
|
6
|
+
# one profile each (herdrchat start --profile telegram).
|
|
7
|
+
HERDRCHAT_CHAT=feishu
|
|
8
|
+
|
|
9
|
+
# feishu
|
|
4
10
|
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
|
|
5
11
|
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
6
12
|
FEISHU_OWNER_OPEN_ID=
|
|
7
13
|
|
|
14
|
+
# telegram: the token from @BotFather (turn topic mode on there); your user id, which the
|
|
15
|
+
# bot tells you when you message it
|
|
16
|
+
TELEGRAM_BOT_TOKEN=
|
|
17
|
+
TELEGRAM_OWNER_ID=
|
|
18
|
+
|
|
8
19
|
HERDR_SESSIONS=session-a,session-b
|
|
9
20
|
# once /auth is done, spaces whose groups are muted (only an @ rings): `all` (default),
|
|
10
21
|
# empty for none, or spaces, comma-separated
|
|
@@ -15,3 +26,6 @@ HERDR_SILENT_SPACES=
|
|
|
15
26
|
HERDR_BOT_TAGS=on
|
|
16
27
|
# your sidebar labels, once /auth is done: on (default) or off
|
|
17
28
|
HERDR_USER_TAGS=on
|
|
29
|
+
# what a new room gets of the conversation before it: file,folded (default: all of it as a
|
|
30
|
+
# file, the newest part in a folded post), file, folded, or empty for nothing
|
|
31
|
+
HERDR_HISTORY=file,folded
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# herdrchat architecture
|
|
2
|
+
|
|
3
|
+
herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu now,
|
|
4
|
+
Telegram next, then Slack and Discord) and **harnesses** (Claude Code now, then codex
|
|
5
|
+
and pi). The core between them knows neither. This page says where everything is and
|
|
6
|
+
how to add one of each.
|
|
7
|
+
|
|
8
|
+
## The parts
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
bin/
|
|
12
|
+
cli.mjs herdrchat init|start|stop|status|logs
|
|
13
|
+
herdrchat.mjs wires the parts: config, log, the chat app, the core
|
|
14
|
+
lib/
|
|
15
|
+
core/ the same for every chat app and every harness
|
|
16
|
+
engine.mjs follows hc, keeps one room per herdr-app tab, posts what agents
|
|
17
|
+
say, dialogs and clicks, what you type, the overview
|
|
18
|
+
hc.mjs events.mjs hc: commands, and its live event stream
|
|
19
|
+
views.mjs which view of a terminal is followed (hc core id + tN)
|
|
20
|
+
cursor.mjs home.mjs what was posted already; reading an agent's log files
|
|
21
|
+
menu.mjs a dialog's options, read from the screen
|
|
22
|
+
config.mjs log.mjs config.env, state.json, the daily log
|
|
23
|
+
readers/ one reader per harness (the contract is in index.mjs)
|
|
24
|
+
chats/
|
|
25
|
+
contract.mjs what a chat app gives the core, and the check for it
|
|
26
|
+
index.mjs the list of chat apps
|
|
27
|
+
feishu/ index.mjs (the contract), client.mjs, overview.mjs, ...
|
|
28
|
+
test/
|
|
29
|
+
fakes.mjs a fake chat app, a fake hc and a fake reader
|
|
30
|
+
engine.test.mjs the core, run on the fakes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The rules that keep it this way:
|
|
34
|
+
|
|
35
|
+
- The core never imports from `lib/chats/`, and a chat app never imports from the
|
|
36
|
+
core's engine. They meet only through the contract in `lib/chats/contract.mjs`.
|
|
37
|
+
- Everything a chat app does is in its own folder, including what only it has (Feishu's
|
|
38
|
+
tags, avatars and acting as you).
|
|
39
|
+
- The core gets hc, the readers and state.json passed in, so the tests swap them for
|
|
40
|
+
fakes. A behaviour of the core gets a test in `test/engine.test.mjs`.
|
|
41
|
+
- Words the core uses: a **room** is where one tab is mirrored (a Feishu group, a
|
|
42
|
+
Telegram topic); `k` is the core's key for a tab's terminal; `room` and `message`
|
|
43
|
+
are the app's own ids, which the core keeps as given.
|
|
44
|
+
|
|
45
|
+
## Adding a chat app
|
|
46
|
+
|
|
47
|
+
1. Read the app's official docs first, for each line of the contract: how a room is
|
|
48
|
+
made (a group, a topic, a channel), how a message is edited, buttons and their
|
|
49
|
+
callbacks, reactions, images both ways, how the phone is kept quiet, the rate limits.
|
|
50
|
+
Write down what it cannot do and what stands in for it.
|
|
51
|
+
2. Make `lib/chats/<app>/index.mjs` that exports `requires` (the config.env keys it
|
|
52
|
+
cannot start without) and `create(ctx)` returning every member of `CHAT_MEMBERS` in
|
|
53
|
+
`lib/chats/contract.mjs`. Keep the app's API calls in a `client.mjs` beside it.
|
|
54
|
+
3. List it in `lib/chats/index.mjs`.
|
|
55
|
+
4. Add its keys to `config.env.example` and to the comment at the top of
|
|
56
|
+
`lib/core/config.mjs`.
|
|
57
|
+
5. Add a test that `checkChat(create(...))` passes (see the Feishu one in
|
|
58
|
+
`test/engine.test.mjs`), and tests for its own message conversions.
|
|
59
|
+
6. Add `skills/herdrchat/references/<app>.md`: setting the app up, step by step.
|
|
60
|
+
7. Run it in its own profile (`herdrchat start --profile <app>` with
|
|
61
|
+
`HERDRCHAT_CHAT=<app>` in that profile's config.env), so the running Feishu bot is
|
|
62
|
+
not touched.
|
|
63
|
+
|
|
64
|
+
## Adding a harness
|
|
65
|
+
|
|
66
|
+
1. Read how the agent keeps its conversation on disk, from its docs or its source: where
|
|
67
|
+
the log is, how to find the one for a running terminal, how a turn's last answer is
|
|
68
|
+
marked.
|
|
69
|
+
2. Write `lib/core/readers/<agent>.mjs` exporting `app` (the name the agent gives in its
|
|
70
|
+
hc status, `status.app`), `processes` (the names its process runs under, as hc's
|
|
71
|
+
`foreground` reports them), `after(session, address, cursor)` and
|
|
72
|
+
`rest(session, address, cursor)`. The items it returns are described at the top of
|
|
73
|
+
`lib/core/readers/index.mjs`.
|
|
74
|
+
3. List it in `LISTED` in `lib/core/readers/index.mjs`; `checkReader` checks it there.
|
|
75
|
+
4. Add tests with a sample log in `test/readers.test.mjs`.
|
|
76
|
+
5. Add a row to the readers table in `docs/guide.md`.
|
package/docs/guide.md
CHANGED
|
@@ -31,16 +31,16 @@ herdr-app shows them, their status, and when a turn ends (`turn` event: the stat
|
|
|
31
31
|
**A reader gives the conversation.** hc can show only the screen, and the screen is a
|
|
32
32
|
picture: long output is folded, and you cannot tell your words from the agent's. So for
|
|
33
33
|
each agent a reader reads the agent's own records. The status hc reports names the agent
|
|
34
|
-
(`status.app`), and that name picks the reader (`lib/readers/`):
|
|
34
|
+
(`status.app`), and that name picks the reader (`lib/core/readers/`):
|
|
35
35
|
|
|
36
36
|
| `status.app` | reader | reads |
|
|
37
37
|
|---|---|---|
|
|
38
|
-
| `claude-code` | `lib/readers/claude.mjs` | its transcript: the terminal's foreground pid names the session in `~/.claude/sessions/<pid>.json` |
|
|
38
|
+
| `claude-code` | `lib/core/readers/claude.mjs` | its transcript: the terminal's foreground pid names the session in `~/.claude/sessions/<pid>.json` |
|
|
39
39
|
| anything else | none (default) | the screen, through `hc read` |
|
|
40
40
|
|
|
41
41
|
On another machine, a reader reads the same files through `hc exec --on`. To add an
|
|
42
|
-
agent, write `lib/readers/<agent>.mjs` (the contract is at the top of
|
|
43
|
-
`lib/readers/index.mjs`) and list it there.
|
|
42
|
+
agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
|
|
43
|
+
`lib/core/readers/index.mjs`) and list it there.
|
|
44
44
|
|
|
45
45
|
## What a group shows
|
|
46
46
|
|
|
@@ -56,7 +56,9 @@ agent, write `lib/readers/<agent>.mjs` (the contract is at the top of
|
|
|
56
56
|
- Images too: ones it looked at (a screenshot, an image file it read) and ones you
|
|
57
57
|
pasted in the terminal, shown in the card.
|
|
58
58
|
- What you sent from the group is not repeated.
|
|
59
|
-
-
|
|
59
|
+
- Anything else (a shell, a tool, an agent that has quit): nothing by itself. A terminal
|
|
60
|
+
counts as an agent only while its foreground process is that agent, so a status left
|
|
61
|
+
over after it quits posts nothing.
|
|
60
62
|
- A turn that ends on `blocked` (permission, question, auth): what it said first, then
|
|
61
63
|
a red "⚠️ Needs you" card. When the screen shows a numbered menu (as Claude Code's
|
|
62
64
|
permission and question dialogs do), the card shows only the dialog, as text, with a
|
|
@@ -103,7 +105,17 @@ from one dialog straight to the next. One dialog gets one card.
|
|
|
103
105
|
the text shown on screen, the only version on disk.
|
|
104
106
|
|
|
105
107
|
On the first start the bot posts only what happens from then on, never older history.
|
|
106
|
-
A
|
|
108
|
+
A new room is not empty: it gets the conversation before it, all of it as a `.md` file
|
|
109
|
+
and its newest part in one folded post, silent and never repeated (`HERDR_HISTORY` says
|
|
110
|
+
which: `file,folded` by default, `file`, `folded`, or empty for neither). `/history` in a
|
|
111
|
+
room posts both again.
|
|
112
|
+
|
|
113
|
+
In a terminal that runs no agent, what you type gets one reply: its screen a moment
|
|
114
|
+
later, without blank lines.
|
|
115
|
+
|
|
116
|
+
A group belongs to its herdr-app tab (by the tab's id): when the tab's terminal is
|
|
117
|
+
replaced, the new terminal carries on in the same group. A message that cannot be
|
|
118
|
+
delivered (the terminal has ended) says so plainly.
|
|
107
119
|
|
|
108
120
|
## Commands
|
|
109
121
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// What a chat app gives the core (lib/core/engine.mjs). Each app is a folder,
|
|
2
|
+
// lib/chats/<app>/, whose index.mjs exports create(ctx) returning an object with every
|
|
3
|
+
// member below, and `requires`: the config.env keys it cannot start without. Register it
|
|
4
|
+
// in lib/chats/index.mjs. `checkChat` runs at start and in the tests, so an app that
|
|
5
|
+
// misses one fails at once, not when a terminal first needs it.
|
|
6
|
+
//
|
|
7
|
+
// The core's words: a "room" is where one herdr-app tab is mirrored (a Feishu group, a
|
|
8
|
+
// Telegram topic); `k` is the core's key for a tab's terminal, stable while the room is
|
|
9
|
+
// that terminal's; `room` and `message` are the app's own ids, kept by the core as given.
|
|
10
|
+
// Markdown is the core's: **bold**, `code`, ``` blocks, > quotes, [text](url); an app
|
|
11
|
+
// turns it into what it can show. A `look` is {title, tone, markdown}; tone is one of
|
|
12
|
+
// working | closed | needs | done | good | bad.
|
|
13
|
+
//
|
|
14
|
+
// ctx: {cfg, store: {read, write}, log, logWarn, logError, stateDir,
|
|
15
|
+
// lane(k, label, fn): run fn in the room's lane, scopeOf(k): its session or machine}
|
|
16
|
+
|
|
17
|
+
export const CHAT_MEMBERS = {
|
|
18
|
+
name: 'string: the app, as in HERDRCHAT_CHAT',
|
|
19
|
+
ready: '() -> boolean: the owner is known (no room is made before)',
|
|
20
|
+
help: 'string[]: lines it adds to the direct chat\'s help',
|
|
21
|
+
liveBytes: 'number: how much text one editable card holds',
|
|
22
|
+
split: '(markdown) -> string[]: one message\'s worth each',
|
|
23
|
+
start: '({onMessage, onAction, onReady}): connect; onMessage({room|null, where, message, text, images}) for the owner\'s messages (room null: the direct chat), onAction({value, message, answer}) -> {toast: {type, text}, card?: {kind: done|dialog, ...}} for button clicks',
|
|
24
|
+
command: '(cmd, args, where) -> boolean: one of its own direct-chat commands, handled',
|
|
25
|
+
openRoom: '({name, intro}) -> room',
|
|
26
|
+
renameRoom: '(room, name)',
|
|
27
|
+
placeRoom: '(k, {space}): what a live room looks like (tags, avatar, quiet)',
|
|
28
|
+
endRoom: '(k): what a room whose terminal ended looks like',
|
|
29
|
+
reviveRoom: '(k): live again',
|
|
30
|
+
post: '(room, markdown) -> message',
|
|
31
|
+
reply: '(where, markdown) -> message: where a message came from',
|
|
32
|
+
postCard: '(room, look) -> message: an editable message',
|
|
33
|
+
editCard: '(message, look)',
|
|
34
|
+
postDialog: '(room, dialog, value) -> message: dialog {title, markdown, menu, doneTitle}; each button sends value plus {pick, label}',
|
|
35
|
+
closeDialog: '(message, dialog, done): no buttons any more, says what was done',
|
|
36
|
+
pin: '(k, room): the room waits for you',
|
|
37
|
+
unpin: '(k)',
|
|
38
|
+
pinned: '() -> k[]',
|
|
39
|
+
ring: '(k, message, text): make the phone ring for this message if it would not',
|
|
40
|
+
markWorking: '(message) -> token: your message reached the terminal',
|
|
41
|
+
markDone: '(message, token): the turn that took it is over',
|
|
42
|
+
image: '(buffer) -> markdown: an image to show in a post',
|
|
43
|
+
postFile: '(room, {name, data: Buffer, caption}) -> message: a file in the room, silent',
|
|
44
|
+
postFolded: '(room, {title, markdown}) -> message: a post that shows its title and opens on a tap, silent',
|
|
45
|
+
foldedBytes: 'number: how much text one folded post holds',
|
|
46
|
+
download: '(message, ref, file): an image you sent, saved to file',
|
|
47
|
+
tellOwner: '(look): a message in the direct chat',
|
|
48
|
+
showOverview: '(rows, {fresh}): rows [{name, address, state, kind, program, since, room}]',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const TYPES = { name: 'string', help: 'object', liveBytes: 'number', foldedBytes: 'number' };
|
|
52
|
+
|
|
53
|
+
// Throws, naming every member that is missing or of the wrong kind.
|
|
54
|
+
export function checkChat(chat) {
|
|
55
|
+
const wrong = Object.keys(CHAT_MEMBERS).filter((m) => typeof chat?.[m] !== (TYPES[m] || 'function'));
|
|
56
|
+
if (wrong.length) throw new Error(`chat app ${chat?.name || '?'} does not give: ${wrong.join(', ')} (lib/chats/contract.mjs)`);
|
|
57
|
+
return chat;
|
|
58
|
+
}
|
|
@@ -147,6 +147,24 @@ export async function uploadImage(client, buffer, type = 'message') {
|
|
|
147
147
|
return key;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// A file in a chat: uploaded (file_type "stream": any kind; at most 30 MB, never empty;
|
|
151
|
+
// im:resource), then sent by its key as a "file" message. Returns the message id.
|
|
152
|
+
export async function sendFile(client, chatId, name, buffer) {
|
|
153
|
+
const up = await client.im.v1.file.create({ data: { file_type: 'stream', file_name: name, file: buffer } });
|
|
154
|
+
const key = up?.file_key || up?.data?.file_key;
|
|
155
|
+
if (!key) throw new Error(`feishu file.create: no file_key (${JSON.stringify(up).slice(0, 200)})`);
|
|
156
|
+
const res = await createMessage(client, 'chat_id', chatId, 'file', JSON.stringify({ file_key: key }));
|
|
157
|
+
return res?.data?.message_id || null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A card that shows its title and opens on a tap (card JSON 2.0 collapsible_panel).
|
|
161
|
+
export function foldedCard(title, markdown) {
|
|
162
|
+
return {
|
|
163
|
+
schema: '2.0',
|
|
164
|
+
body: { elements: [{ tag: 'collapsible_panel', expanded: false, header: { title: { tag: 'markdown', content: `**${title}**` } }, border: { color: 'grey', corner_radius: '5px' }, elements: [{ tag: 'markdown', content: markdown }] }] },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
150
168
|
// An image someone sent in a chat the bot is in, saved to `file`. Needs im:message:readonly.
|
|
151
169
|
export async function downloadImage(client, messageId, imageKey, file) {
|
|
152
170
|
const res = await client.im.v1.messageResource.get({ path: { message_id: messageId, file_key: imageKey }, params: { type: 'image' } });
|
|
@@ -240,12 +258,15 @@ export async function renameChat(client, chatId, name) {
|
|
|
240
258
|
// Messages in (im.message.receive_v1) and card clicks (card.action.trigger), both over
|
|
241
259
|
// the long connection. A click must be answered within 3 seconds; what `onCardAction`
|
|
242
260
|
// returns ({toast, card}) is that answer.
|
|
243
|
-
export function startEvents(cfg, onMessage, onCardAction) {
|
|
261
|
+
export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}) {
|
|
244
262
|
const ws = new Lark.WSClient({
|
|
245
263
|
appId: cfg.appId,
|
|
246
264
|
appSecret: cfg.appSecret,
|
|
247
265
|
loggerLevel: Lark.LoggerLevel.warn,
|
|
248
|
-
onReady: () =>
|
|
266
|
+
onReady: () => {
|
|
267
|
+
console.log('[feishu] connected');
|
|
268
|
+
onReady();
|
|
269
|
+
},
|
|
249
270
|
onReconnecting: () => console.warn('[feishu] reconnecting'),
|
|
250
271
|
onReconnected: () => console.log('[feishu] reconnected'),
|
|
251
272
|
onError: (err) => console.error(`[feishu] connection failed: ${err.message}`),
|