herdrchat 1.0.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/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 +1 -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
|
@@ -7,10 +7,14 @@ import { execFile } from 'node:child_process';
|
|
|
7
7
|
// it the text arrives as if you typed it.
|
|
8
8
|
const { HC_TERMINAL_ID, HC_SCOPE, HC_TERMINAL_SOCKET, ...ENV } = process.env;
|
|
9
9
|
|
|
10
|
+
// `herdrchat start` passes hc's full path: the core that runs the bot may have a PATH
|
|
11
|
+
// without it, and a bot that cannot run hc sees nothing.
|
|
12
|
+
const HC = process.env.HERDRCHAT_HC || 'hc';
|
|
13
|
+
|
|
10
14
|
export function hc(session, args, { timeoutMs = 15000 } = {}) {
|
|
11
15
|
return new Promise((resolve, reject) => {
|
|
12
16
|
execFile(
|
|
13
|
-
|
|
17
|
+
HC,
|
|
14
18
|
args,
|
|
15
19
|
{ env: { ...ENV, HC_SESSION: session }, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
|
16
20
|
(err, stdout, stderr) => {
|
|
@@ -34,6 +38,16 @@ export async function machines(session) {
|
|
|
34
38
|
return (await json(session, ['ls'])).machines || [];
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
// The id of a session's own core, and the cores it is connected to as machines:
|
|
42
|
+
// { self, links: [{ name, id, state }] }. A link's id is the id of the core it reaches.
|
|
43
|
+
export async function cores(session) {
|
|
44
|
+
const [status, list] = await Promise.all([json(session, ['server', 'status']), json(session, ['machine', 'ls'])]);
|
|
45
|
+
return {
|
|
46
|
+
self: status.core?.id || null,
|
|
47
|
+
links: (list.machines || []).map(({ name, id, state }) => ({ name, id, state })),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
export async function getTerminal(session, address) {
|
|
38
52
|
return json(session, ['get', address]);
|
|
39
53
|
}
|
|
@@ -71,12 +85,46 @@ function read(session, address, lines = 0) {
|
|
|
71
85
|
return hc(session, ['read', address, '--lines', String(lines)]);
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
// The last `lines` lines of a terminal's screen
|
|
88
|
+
// The last `lines` lines of a terminal's screen (dialogs, and replies in a terminal with no agent).
|
|
75
89
|
export async function screenTail(session, address, lines) {
|
|
76
90
|
const out = await read(session, address, lines);
|
|
77
91
|
return out.split('\n').slice(-lines).join('\n').trimEnd();
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
// hc could not reach the machine a terminal is on (`"kind":"unavailable"`).
|
|
95
|
+
export function isUnavailable(err) {
|
|
96
|
+
return /"kind":"unavailable"/.test(err?.message || '');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A failed hc call as a person reads it: hc's own message, without the command line or
|
|
100
|
+
// its JSON wrapping.
|
|
101
|
+
export function plainError(err) {
|
|
102
|
+
const raw = err?.message || String(err);
|
|
103
|
+
const json = raw.match(/\{"error":.*\}\s*$/);
|
|
104
|
+
if (json) {
|
|
105
|
+
try {
|
|
106
|
+
const { message, kind } = JSON.parse(json[0]).error;
|
|
107
|
+
if (kind === 'finished') return 'this terminal has ended.';
|
|
108
|
+
if (message) return message;
|
|
109
|
+
} catch {
|
|
110
|
+
// not hc's JSON
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return raw.replace(/^hc [^:]*: /, '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A screen as people read it: no trailing spaces, no blank lines at the ends, never two
|
|
117
|
+
// blank lines in a row; with `last`, only its last `last` lines (a dialog sits at the bottom).
|
|
118
|
+
export function tidyScreen(text, last = Infinity) {
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const line of (text || '').split('\n').map((l) => l.trimEnd())) {
|
|
121
|
+
if (!line && (!lines.length || !lines[lines.length - 1])) continue;
|
|
122
|
+
lines.push(line);
|
|
123
|
+
}
|
|
124
|
+
while (lines.length && !lines[lines.length - 1]) lines.pop();
|
|
125
|
+
return lines.slice(-last).join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
// "machine/tN" for a terminal on a connected machine, "tN" here.
|
|
81
129
|
export function machineOf(address) {
|
|
82
130
|
const i = address.lastIndexOf('/');
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
// goes to stdout and to logs/<YYYY-MM-DD>.log in the state folder (KEEP_DAYS kept), as
|
|
3
3
|
// "<time> <LEVEL> <text>". `capture()` sends console output (the SDK, the event stream)
|
|
4
4
|
// through it too, and writes an uncaught error with its stack before the process exits
|
|
5
|
-
// (`herdrchat start` keeps it running)
|
|
5
|
+
// (`herdrchat start` keeps it running); `onCrash` gets a few seconds first, to say so
|
|
6
|
+
// where you will see it. No token or secret is ever passed to it.
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import util from 'node:util';
|
|
9
10
|
|
|
10
11
|
const KEEP_DAYS = 14;
|
|
12
|
+
const CRASH_WAIT_MS = 3000;
|
|
11
13
|
|
|
12
14
|
export function createLog(dir) {
|
|
13
15
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -43,14 +45,15 @@ export function createLog(dir) {
|
|
|
43
45
|
error: (...args) => write('ERROR', args),
|
|
44
46
|
};
|
|
45
47
|
|
|
46
|
-
log.capture = () => {
|
|
48
|
+
log.capture = ({ onCrash } = {}) => {
|
|
47
49
|
console.log = (...args) => write('INFO', args);
|
|
48
50
|
console.info = console.log;
|
|
49
51
|
console.warn = (...args) => write('WARN', args);
|
|
50
52
|
console.error = (...args) => write('ERROR', args);
|
|
51
53
|
process.on('uncaughtException', (err) => {
|
|
52
54
|
write('ERROR', ['uncaught:', err]);
|
|
53
|
-
|
|
55
|
+
const said = onCrash ? Promise.resolve().then(() => onCrash(err)).catch(() => {}) : Promise.resolve();
|
|
56
|
+
Promise.race([said, new Promise((r) => setTimeout(r, CRASH_WAIT_MS))]).finally(() => process.exit(1));
|
|
54
57
|
});
|
|
55
58
|
process.on('unhandledRejection', (err) => write('ERROR', ['unhandled rejection:', err]));
|
|
56
59
|
};
|
|
@@ -90,6 +90,7 @@ export function conversationAfter(lines, cursor) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export const app = 'claude-code';
|
|
93
|
+
export const processes = ['claude'];
|
|
93
94
|
|
|
94
95
|
// What is new since `cursor`: {items, file}, or null when the terminal runs no readable
|
|
95
96
|
// Claude. When the session moved to a new transcript (/clear, resume), what is left in
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Readers: how the bot gets what an agent said from the agent's own records, so nothing
|
|
2
|
+
// is lost and the group shows it while the agent works. hc stays the backbone (which
|
|
3
|
+
// terminals exist, their status, when a turn ends, typing into them); a reader adds the
|
|
4
|
+
// conversation. A terminal with no running agent is not mirrored (see agentOf).
|
|
5
|
+
//
|
|
6
|
+
// A reader is a module that exports:
|
|
7
|
+
// app the name the agent gives in its hc status (`status.app`)
|
|
8
|
+
// processes the names its process runs under (hc's `foreground`)
|
|
9
|
+
// after(session, address, cursor) -> {items, file} | null: what is new since `cursor`
|
|
10
|
+
// (null: this terminal is not readable); `file` is
|
|
11
|
+
// the log it read, kept in the cursor
|
|
12
|
+
// rest(session, address, cursor) -> items: what `cursor.file` still holds after the
|
|
13
|
+
// program is gone
|
|
14
|
+
// Items are {role: 'you' | 'ai' | 'tool', text, at, id, end?} (`end` marks the turn's last
|
|
15
|
+
// answer) or {role: 'image', from: 'you' | 'ai', media, data (base64), at, id}.
|
|
16
|
+
// Cursors are in ../cursor.mjs.
|
|
17
|
+
//
|
|
18
|
+
// To add an agent: write lib/core/readers/<agent>.mjs and list it in LISTED below
|
|
19
|
+
// (docs/architecture.md, "Adding a harness").
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import * as claude from './claude.mjs';
|
|
22
|
+
|
|
23
|
+
// What every reader exports (see above); checked for each one listed here when this
|
|
24
|
+
// loads, so a reader that misses one fails at start, not when its agent first runs.
|
|
25
|
+
export const READER_MEMBERS = { app: 'string', processes: 'object', after: 'function', rest: 'function' };
|
|
26
|
+
export function checkReader(reader) {
|
|
27
|
+
const wrong = Object.entries(READER_MEMBERS).filter(([m, type]) => typeof reader?.[m] !== type);
|
|
28
|
+
if (wrong.length) throw new Error(`reader ${reader?.app || '?'} does not give: ${wrong.map(([m]) => m).join(', ')} (lib/core/readers/index.mjs)`);
|
|
29
|
+
return reader;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const LISTED = [claude].map(checkReader);
|
|
33
|
+
const READERS = new Map(LISTED.map((r) => [r.app, r]));
|
|
34
|
+
|
|
35
|
+
export function readerFor(app) {
|
|
36
|
+
return READERS.get(app) || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The agent a terminal runs right now, as its reader, or null: a group mirrors agents
|
|
40
|
+
// only, never a shell or another program. hc's status can outlive the agent (it still
|
|
41
|
+
// said "blocked" after Claude quit, and a tool run after it was posted as a dialog), so
|
|
42
|
+
// the foreground process must be that agent too; with no foreground known, the status
|
|
43
|
+
// alone decides, so a terminal is never silenced for missing data.
|
|
44
|
+
export function agentOf(record) {
|
|
45
|
+
const reader = readerFor(record?.status?.app);
|
|
46
|
+
const fg = record?.foreground;
|
|
47
|
+
if (!reader || !fg) return reader;
|
|
48
|
+
return [fg.name, path.basename(fg.argv?.[0] || '')].some((n) => reader.processes.includes(n)) ? reader : null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// One terminal can be seen several ways: in its own hc session, and through every
|
|
2
|
+
// followed session that is connected to that session's core as a machine (under any
|
|
3
|
+
// name). What a terminal is comes from hc, never from names: the id of the core it runs
|
|
4
|
+
// on (`hc server status` for a session's own core, `hc machine ls` for the cores it is
|
|
5
|
+
// connected to) plus its tN on that core.
|
|
6
|
+
//
|
|
7
|
+
// The bot follows each terminal through one view: its own session when that session is
|
|
8
|
+
// followed, else the first followed session (config order) with a live connection to
|
|
9
|
+
// its core.
|
|
10
|
+
import { machineOf } from './hc.mjs';
|
|
11
|
+
|
|
12
|
+
// cores: Map session -> { self: core id, links: [{ name, id, state }] }
|
|
13
|
+
export function coreViews(sessions, cores) {
|
|
14
|
+
// the core a view's terminal runs on, or null when it cannot be told (unknown session
|
|
15
|
+
// or machine, or a machine reached through another machine)
|
|
16
|
+
function coreOf(session, address) {
|
|
17
|
+
const info = cores.get(session);
|
|
18
|
+
const machine = machineOf(address);
|
|
19
|
+
if (!info || machine?.includes('/')) return null;
|
|
20
|
+
if (!machine) return info.self || null;
|
|
21
|
+
return info.links.find((l) => l.name === machine)?.id || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// "<core id>|tN": the same for every view of one terminal
|
|
25
|
+
function identity(session, address) {
|
|
26
|
+
const core = coreOf(session, address);
|
|
27
|
+
return core ? `${core}|${address.slice(address.lastIndexOf('/') + 1)}` : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// the view that follows a core's terminals: { session, machine } (machine null: directly)
|
|
31
|
+
function ownerOf(core) {
|
|
32
|
+
const direct = sessions.find((s) => cores.get(s)?.self === core);
|
|
33
|
+
if (direct) return { session: direct, machine: null };
|
|
34
|
+
for (const session of sessions) {
|
|
35
|
+
const link = cores.get(session)?.links.find((l) => l.id === core && l.state === 'connected');
|
|
36
|
+
if (link) return { session, machine: link.name };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// true or false, or null when the cores cannot tell (the caller falls back)
|
|
42
|
+
function owns(session, address) {
|
|
43
|
+
const core = coreOf(session, address);
|
|
44
|
+
if (!core) return null;
|
|
45
|
+
const owner = ownerOf(core);
|
|
46
|
+
return !!owner && owner.session === session && owner.machine === machineOf(address);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// false while the machine a view reaches its terminal through is not connected
|
|
50
|
+
function reachable(session, address) {
|
|
51
|
+
const machine = machineOf(address);
|
|
52
|
+
if (!machine) return true;
|
|
53
|
+
const link = cores.get(session)?.links.find((l) => l.name === machine.split('/')[0]);
|
|
54
|
+
return !link || link.state === 'connected';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { identity, owns, reachable };
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ in `~/.config/herdrchat/<profile>/config.env` and its state in
|
|
|
22
22
|
| `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. |
|
|
23
23
|
| `/herdrchat logs` | `herdrchat logs --errors` (or `-n 100`); explain them with `references/ops.md` "Logs". |
|
|
24
24
|
| `/herdrchat auth` | The user sends `/auth` to the bot and allows it on Feishu's page; the app needs the user scopes in `references/feishu.md`. Then groups are muted, labels and pins follow. |
|
|
25
|
-
| `/herdrchat config` | Change a setting in `config.env` with the user (sessions, `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES`, `HERDR_BOT_TAGS`, `HERDR_USER_TAGS`; each is explained in `config.env.example`), read the file back, then restart. |
|
|
25
|
+
| `/herdrchat config` | Change a setting in `config.env` with the user (sessions, `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES`, `HERDR_BOT_TAGS`, `HERDR_USER_TAGS`, `HERDR_HISTORY`, `HERDRCHAT_CHAT`; each is explained in `config.env.example`), read the file back, then restart. |
|
|
26
26
|
| `/herdrchat fix` | Find the cause from `herdrchat status`, the logs and the live result, then fix it: `references/ops.md`. |
|
|
27
27
|
| `/herdrchat feishu <task>` | Anything in the Feishu developer console: scopes, callbacks, publishing, the icon, deleting an app: `references/feishu.md`. |
|
|
28
28
|
|
|
@@ -17,12 +17,25 @@ Below, `S` is the first session in `HERDR_SESSIONS`.
|
|
|
17
17
|
HC_SESSION=S hc get herdrchat --json # state "live"
|
|
18
18
|
HC_SESSION=S hc read herdrchat --lines 40 # its terminal: start lines, "[feishu] connected"
|
|
19
19
|
herdrchat status # running, folders, /auth, errors today
|
|
20
|
-
herdrchat start # restart
|
|
20
|
+
herdrchat start # restart: stops it in every session, waits until it reaches Feishu
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
A restart is needed after any code or `config.env` change. It is safe: what was posted
|
|
24
24
|
is in `D/state.json`, so it picks up where it stopped.
|
|
25
25
|
|
|
26
|
+
While the bot is down the user hears nothing, so:
|
|
27
|
+
|
|
28
|
+
- Never end with the bot down: `herdrchat start` exits 1 with its errors when the bot
|
|
29
|
+
does not reach Feishu within 30 s; fix that before anything else.
|
|
30
|
+
- Never stop or restart the hc core (`hc server stop|restart`) of the session the bot
|
|
31
|
+
runs in without saying so first, and run `herdrchat start` right after.
|
|
32
|
+
- Never start an hc core from an agent's shell: the core passes that shell's variables
|
|
33
|
+
(`CLAUDECODE`, `CLAUDE_CODE_*`) to every terminal, and an agent started there treats
|
|
34
|
+
itself as a sub-agent (Claude Code then saves no transcript). Start it with those
|
|
35
|
+
variables removed (`env -u CLAUDECODE -u CLAUDE_CODE_CHILD_SESSION … hc server start`).
|
|
36
|
+
- A crash posts "🔴 herdrchat is down" in the direct chat (at most every 10 minutes) and
|
|
37
|
+
the next connection posts "✅ herdrchat is back" with how long it was gone.
|
|
38
|
+
|
|
26
39
|
## Logs
|
|
27
40
|
|
|
28
41
|
`D/logs/<YYYY-MM-DD>.log`, one file a day, 14 kept, lines `<time> <LEVEL> <text>`:
|
|
@@ -61,7 +74,7 @@ your token after `/auth`: never print it.
|
|
|
61
74
|
## Did the change take effect
|
|
62
75
|
|
|
63
76
|
Check the live result, not only the code: count what actually happened in Feishu with
|
|
64
|
-
the bot's own client (`lib/feishu.mjs` `createClient` with `loadConfig()`), for example:
|
|
77
|
+
the bot's own client (`lib/chats/feishu/client.mjs` `createClient` with `loadConfig()`), for example:
|
|
65
78
|
|
|
66
79
|
- mentions in a group since a time: `im.v1.message.list` on the chat, then each item's
|
|
67
80
|
`mentions` (a mention inside a card is not counted by Feishu, so it does not ring);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Telegram: setting up the bot
|
|
2
|
+
|
|
3
|
+
A Telegram bot cannot make groups, so herdrchat puts each herdr-app tab in a topic of
|
|
4
|
+
your private chat with the bot; the chat's main thread is the overview and the commands
|
|
5
|
+
(`/ls`, `/new`, `/kill`; in a topic `/screen`, `/answer`, `/esc`). Posts are silent;
|
|
6
|
+
"needs you" and "done" ring.
|
|
7
|
+
|
|
8
|
+
It runs as its own profile, beside a Feishu one:
|
|
9
|
+
|
|
10
|
+
1. In Telegram, message **@BotFather**: `/newbot`, pick a name and a username, and keep
|
|
11
|
+
the token it gives. Never paste the token into a chat or a log.
|
|
12
|
+
2. Turn on topics for the bot in @BotFather (its mini app, "Open": the bot's settings,
|
|
13
|
+
Threaded Mode). Check: `getMe` answers `has_topics_enabled: true`.
|
|
14
|
+
3. Make the profile's config: `herdrchat init --profile telegram`, then in
|
|
15
|
+
`~/.config/herdrchat/telegram/config.env` set `HERDRCHAT_CHAT=telegram`, the token as
|
|
16
|
+
`TELEGRAM_BOT_TOKEN`, and `HERDR_SESSIONS`.
|
|
17
|
+
4. `herdrchat start --profile telegram`, then message the bot once: it answers with your
|
|
18
|
+
`TELEGRAM_OWNER_ID=...`. Put that in the config and start it again. The topics appear,
|
|
19
|
+
one per tab (Telegram allows about one message a second, so many tabs take a moment).
|
|
20
|
+
|
|
21
|
+
Check the token without printing it: read `TELEGRAM_BOT_TOKEN` from the config in a short
|
|
22
|
+
`node -e`, call `https://api.telegram.org/bot<token>/getMe`, print only `username` and
|
|
23
|
+
`has_topics_enabled`.
|
package/lib/readers/index.mjs
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// Readers: how the bot gets what an agent said from the agent's own records, so nothing
|
|
2
|
-
// is lost and the group shows it while the agent works. hc stays the backbone (which
|
|
3
|
-
// terminals exist, their status, when a turn ends, typing into them); a reader adds the
|
|
4
|
-
// conversation. A program with no reader gets the default: its screen through `hc read`.
|
|
5
|
-
//
|
|
6
|
-
// A reader is a module that exports:
|
|
7
|
-
// app the name the agent gives in its hc status (`status.app`)
|
|
8
|
-
// after(session, address, cursor) -> {items, file} | null: what is new since `cursor`
|
|
9
|
-
// (null: this terminal is not readable); `file` is
|
|
10
|
-
// the log it read, kept in the cursor
|
|
11
|
-
// rest(session, address, cursor) -> items: what `cursor.file` still holds after the
|
|
12
|
-
// program is gone
|
|
13
|
-
// Items are {role: 'you' | 'ai' | 'tool', text, at, id, end?} (`end` marks the turn's last
|
|
14
|
-
// answer) or {role: 'image', from: 'you' | 'ai', media, data (base64), at, id}.
|
|
15
|
-
// Cursors are in ../cursor.mjs.
|
|
16
|
-
//
|
|
17
|
-
// To add an agent: write lib/readers/<agent>.mjs and list it here.
|
|
18
|
-
import * as claude from './claude.mjs';
|
|
19
|
-
|
|
20
|
-
const READERS = new Map([claude].map((r) => [r.app, r]));
|
|
21
|
-
|
|
22
|
-
export function readerFor(app) {
|
|
23
|
-
return READERS.get(app) || null;
|
|
24
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|