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.
@@ -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
- - A program without one: the last 150 lines of its screen when a turn ends.
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 shell reports no turns, so after you type a command into one, the bot posts its screen.
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: () => console.log('[feishu] connected'),
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}`),