@grknbyk/agent-wire 0.13.1 → 0.13.3
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 +15 -7
- package/bin/agent-wire.mjs +11 -0
- package/package.json +1 -1
- package/src/inbox.mjs +7 -3
- package/src/mcp.mjs +25 -6
- package/src/protocol.mjs +32 -9
- package/src/slack.mjs +5 -0
package/README.md
CHANGED
|
@@ -20,10 +20,10 @@ produces a conversation nobody can audit. In a channel, the humans who own those
|
|
|
20
20
|
agents read the whole exchange, scroll back through it, and step in by typing.
|
|
21
21
|
|
|
22
22
|
```
|
|
23
|
-
🔥 grkn => mira
|
|
23
|
+
🔥 grkn => *mira
|
|
24
24
|
migration 0042 is on dev now, txn_date is a DATE not a TIMESTAMP
|
|
25
25
|
|
|
26
|
-
⚡ mira => grkn
|
|
26
|
+
⚡ mira => *grkn
|
|
27
27
|
got it, rewriting the report query
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -398,8 +398,9 @@ do with it:
|
|
|
398
398
|
| `nobody` | A human wrote without naming any agent | Read it as context, stay quiet |
|
|
399
399
|
|
|
400
400
|
Slack has no real mention for an agent, so `@grkn` is ordinary text that
|
|
401
|
-
agent-wire looks for itself.
|
|
402
|
-
|
|
401
|
+
agent-wire looks for itself. `*grkn` calls it too, since that is the marker the
|
|
402
|
+
header uses. The match is literal and case-insensitive, and it stops at a word
|
|
403
|
+
boundary, so neither form fires for `@grknbyk`.
|
|
403
404
|
|
|
404
405
|
Nothing is filtered by this. Every message still arrives, still goes in the
|
|
405
406
|
inbox, and is still readable. It only decides who speaks first. Your own
|
|
@@ -411,11 +412,18 @@ Every message carries a handle at the right edge of its header line, padded to
|
|
|
411
412
|
column 60 so a scrolled channel has one straight edge to read down:
|
|
412
413
|
|
|
413
414
|
```
|
|
414
|
-
🔥 grkn => sinan
|
|
415
|
-
🚀 hakan-akduman =>
|
|
416
|
-
🛰️ mehmet-emin-kaya =>
|
|
415
|
+
🔥 grkn => *sinan wms-agents@k7m2pq
|
|
416
|
+
🚀 hakan-akduman => @Sinan wms-agents@zpbxdf
|
|
417
|
+
🛰️ mehmet-emin-kaya => all wms-agents@8g88zm
|
|
417
418
|
```
|
|
418
419
|
|
|
420
|
+
The recipient carries the same two markers the status panel uses: `*` for an
|
|
421
|
+
agent, `@` for a person. One name often belongs to both, the agent `sinan` and
|
|
422
|
+
the colleague Sinan, and without the marker you cannot tell which one a line was
|
|
423
|
+
addressed to. A name that is neither a pinned agent nor a resolved Slack user
|
|
424
|
+
stays bare, because a marker there would be a guess. The sender is never marked:
|
|
425
|
+
every one of these lines was written by an agent.
|
|
426
|
+
|
|
419
427
|
To point your agent at one line you scrolled past, say "wms-agents@k7m2pq oku".
|
|
420
428
|
It fetches that message whatever channel it came from and whether it was already
|
|
421
429
|
read, which saves copying a Slack timestamp.
|
package/bin/agent-wire.mjs
CHANGED
|
@@ -22,6 +22,7 @@ const USAGE = `agent-wire — message other AI coding agents through Slack
|
|
|
22
22
|
agent-wire off <name> say nothing about it in this session
|
|
23
23
|
agent-wire version print the installed version, which is what a bug report needs
|
|
24
24
|
agent-wire update install the newest published version, cache and all
|
|
25
|
+
agent-wire help print this
|
|
25
26
|
|
|
26
27
|
The three modes belong to one session, identified by the client's session id when
|
|
27
28
|
it publishes one and by the working directory otherwise. The token, the nickname
|
|
@@ -201,8 +202,18 @@ const commands = {
|
|
|
201
202
|
version: showVersion,
|
|
202
203
|
'--version': showVersion,
|
|
203
204
|
'-v': showVersion,
|
|
205
|
+
// Asking for the usage text is not a mistake, so it prints and exits 0. The
|
|
206
|
+
// same text after a wrong command is a complaint and exits 1.
|
|
207
|
+
help: showUsage,
|
|
208
|
+
'--help': showUsage,
|
|
209
|
+
'-h': showUsage,
|
|
204
210
|
};
|
|
205
211
|
|
|
212
|
+
function showUsage() {
|
|
213
|
+
console.log(USAGE);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
206
217
|
function notConfigured() {
|
|
207
218
|
console.log('not configured yet — run `agent-wire setup`');
|
|
208
219
|
return 1;
|
package/package.json
CHANGED
package/src/inbox.mjs
CHANGED
|
@@ -160,9 +160,13 @@ export const findByTs = (ts) => readInbox().find((item) => item.ts === ts) ?? nu
|
|
|
160
160
|
|
|
161
161
|
// Newest first: a ref is short enough to collide eventually, and the one a person
|
|
162
162
|
// just read off the channel is the recent one.
|
|
163
|
-
export
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
export function findByRef(handle) {
|
|
164
|
+
const [channel, ref] = String(handle).toLowerCase().split('@');
|
|
165
|
+
const wanted = ref ?? channel;
|
|
166
|
+
return readInbox()
|
|
167
|
+
.filter((item) => item.ref === wanted && (!ref || !channel || item.channel === channel))
|
|
168
|
+
.at(-1) ?? null;
|
|
169
|
+
}
|
|
166
170
|
|
|
167
171
|
export const readCursor = (channelId) => readJsonCached(paths.cursors, {})[channelId] ?? null;
|
|
168
172
|
|
package/src/mcp.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
|
|
10
|
-
import { MODES, activeChannels, channelMode, findChannel, loadConfig, paths, pollableChannels, scopeId } from './config.mjs';
|
|
10
|
+
import { MODES, activeChannels, channelMode, findChannel, loadConfig, paths, pollableChannels, readJson, scopeId } from './config.mjs';
|
|
11
11
|
import { DEFAULT_COUNT, appendMessages, archive, findByRef, findByTs, markRead, readCursor, selectMessages, writeCursor } from './inbox.mjs';
|
|
12
12
|
import { FINGERPRINT_CHARS, listPeers, signMessage } from './identity.mjs';
|
|
13
13
|
import { refusalFor } from './manners.mjs';
|
|
@@ -249,6 +249,21 @@ function chainOf(replyTo) {
|
|
|
249
249
|
return { conv: parent.conv ?? parent.ts, hop: (Number(parent.hop) || 1) + 1 };
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// A name is an agent once a key has been pinned to it, a human once Slack has
|
|
253
|
+
// resolved it under that name, and unmarked while it is neither. The agent
|
|
254
|
+
// "sinan" and the person Sinan share a name here, so the header has to say which
|
|
255
|
+
// one a message was addressed to.
|
|
256
|
+
function recipientKind(config, to) {
|
|
257
|
+
if (to === 'all') return 'all';
|
|
258
|
+
|
|
259
|
+
const wanted = String(to).toLowerCase();
|
|
260
|
+
if (wanted === String(config.nickname).toLowerCase()) return 'agent';
|
|
261
|
+
if (listPeers().some((peer) => peer.name.toLowerCase() === wanted)) return 'agent';
|
|
262
|
+
|
|
263
|
+
const humans = Object.values(readJson(paths.users, {}));
|
|
264
|
+
return humans.some((name) => String(name).toLowerCase() === wanted) ? 'human' : 'unknown';
|
|
265
|
+
}
|
|
266
|
+
|
|
252
267
|
async function sendText(config, { to, text, channel, replyTo }) {
|
|
253
268
|
const target = findChannel(config, channel);
|
|
254
269
|
if (!target) return `no such channel: ${channel ?? '(none configured)'}`;
|
|
@@ -262,7 +277,9 @@ async function sendText(config, { to, text, channel, replyTo }) {
|
|
|
262
277
|
|
|
263
278
|
const client = slackClient(config.bot_token);
|
|
264
279
|
const ref = mintRef();
|
|
265
|
-
const rendered = formatMessage({
|
|
280
|
+
const rendered = formatMessage({
|
|
281
|
+
mark: config.mark, from: config.nickname, to, toKind: recipientKind(config, to), text, ref, channel: target.name,
|
|
282
|
+
});
|
|
266
283
|
const signature = signMessage(config.private_key, {
|
|
267
284
|
channel: target.id, from: config.nickname, to, conv: chain.conv, hop: chain.hop, text,
|
|
268
285
|
});
|
|
@@ -279,7 +296,7 @@ async function sendText(config, { to, text, channel, replyTo }) {
|
|
|
279
296
|
if (!posted.ok) return `Slack rejected it (${posted.reason})`;
|
|
280
297
|
|
|
281
298
|
recordOwnMessage(config, { ts: posted.ts, target, to, text, chain, ref });
|
|
282
|
-
return `delivered to ${to} in #${target.name} as @${ref}`;
|
|
299
|
+
return `delivered to ${to} in #${target.name} as ${target.name}@${ref}`;
|
|
283
300
|
}
|
|
284
301
|
|
|
285
302
|
// Our own sent messages go into the local log too, so the log is a complete
|
|
@@ -319,7 +336,9 @@ async function postFile(config, { to, path, note, target, chain, logText }) {
|
|
|
319
336
|
});
|
|
320
337
|
const posted = await postMessage(client, {
|
|
321
338
|
channel: target.id,
|
|
322
|
-
rendered: formatMessage({
|
|
339
|
+
rendered: formatMessage({
|
|
340
|
+
mark: config.mark, from: config.nickname, to, toKind: recipientKind(config, to), text, ref, channel: target.name,
|
|
341
|
+
}),
|
|
323
342
|
signature,
|
|
324
343
|
publicKey: config.public_key,
|
|
325
344
|
from: config.nickname,
|
|
@@ -351,7 +370,7 @@ async function sendLongText(config, { to, text, target, chain }) {
|
|
|
351
370
|
unlinkSync(path);
|
|
352
371
|
if (!result.ok) return result.message;
|
|
353
372
|
|
|
354
|
-
return `delivered to ${to} in #${result.channelName} as @${result.ref} — ${text.length} characters, sent as a file`;
|
|
373
|
+
return `delivered to ${to} in #${result.channelName} as ${result.channelName}@${result.ref} — ${text.length} characters, sent as a file`;
|
|
355
374
|
}
|
|
356
375
|
|
|
357
376
|
async function call(name, args, session) {
|
|
@@ -454,7 +473,7 @@ async function call(name, args, session) {
|
|
|
454
473
|
});
|
|
455
474
|
if (!result.ok) return result.message;
|
|
456
475
|
|
|
457
|
-
return `sent ${result.name} to ${args.to} in #${result.channelName} as @${result.ref}`;
|
|
476
|
+
return `sent ${result.name} to ${args.to} in #${result.channelName} as ${result.channelName}@${result.ref}`;
|
|
458
477
|
}
|
|
459
478
|
|
|
460
479
|
if (name === 'archive') return `archived ${archive(args.ts)} message(s)`;
|
package/src/protocol.mjs
CHANGED
|
@@ -68,17 +68,38 @@ export function displayWidth(text) {
|
|
|
68
68
|
// one line costs less than losing a character of somebody's name.
|
|
69
69
|
export const HEADER_WIDTH = 60;
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// The status panel already reads * for an agent and @ for a human, so the header
|
|
72
|
+
// borrows the same two characters rather than inventing a third vocabulary. One
|
|
73
|
+
// name can belong to both (the agent "sinan" and the person Sinan), and the whole
|
|
74
|
+
// point of the marker is that the header says which one a message went to.
|
|
75
|
+
//
|
|
76
|
+
// Only the recipient is marked. Every header line was written by an agent, so a
|
|
77
|
+
// marker on the sender would have been the one field that can never vary.
|
|
78
|
+
//
|
|
79
|
+
// A name nobody has placed stays bare. Guessing "human" for an unknown recipient
|
|
80
|
+
// would put the marker on exactly the messages it is least sure about.
|
|
81
|
+
const RECIPIENT_MARK = { agent: '*', human: '@' };
|
|
82
|
+
|
|
83
|
+
export const addressLine = ({ from, to, toKind }) =>
|
|
84
|
+
`${from} => ${to === 'all' ? 'all' : `${RECIPIENT_MARK[toKind] ?? ''}${to}`}`;
|
|
85
|
+
|
|
86
|
+
export function formatMessage({ mark, from, to, toKind, text, ref, channel }) {
|
|
87
|
+
const left = `${mark ? `${mark} ` : ''}${addressLine({ from, to, toKind })}`;
|
|
73
88
|
if (!ref) return `${left}\n${toSlackText(text)}\n`;
|
|
74
89
|
|
|
75
|
-
|
|
90
|
+
// Shipped once without this: a call site forgot the channel, the handle went out
|
|
91
|
+
// bare, and every test still passed because they all called this directly.
|
|
92
|
+
if (!channel) throw new TypeError(`formatMessage: ref ${ref} with no channel to put in front of it`);
|
|
93
|
+
|
|
94
|
+
const handle = `${channel}@${ref}`;
|
|
76
95
|
const gap = Math.max(1, HEADER_WIDTH - displayWidth(left) - handle.length);
|
|
77
96
|
return `${left}${' '.repeat(gap)}${handle}\n${toSlackText(text)}\n`;
|
|
78
97
|
}
|
|
79
98
|
|
|
80
|
-
//
|
|
81
|
-
|
|
99
|
+
// The leading * on a sender is ours; a *bold* line is not, which is why the name
|
|
100
|
+
// after it still cannot contain one. The recipient's own marker is stripped by the
|
|
101
|
+
// pattern rather than kept, because the payload is what routing reads.
|
|
102
|
+
const HEADER = /^(?:(?<mark>\S+)\s+)?\*?(?<from>[^\s=*]+)\s*=>\s*[@*]?(?<to>\S+?)(?:\s+(?<refChannel>[a-z0-9][\w.-]*)?@(?<ref>[a-z2-9]{4,12}))?$/;
|
|
82
103
|
|
|
83
104
|
export function parseMessage(raw) {
|
|
84
105
|
const lines = fromSlackText(String(raw ?? '').replace(/\r\n/g, '\n')).trim().split('\n');
|
|
@@ -133,10 +154,12 @@ export function addressee(item, myNickname) {
|
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
const text = String(item.text).toLowerCase();
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
157
|
+
for (const prefix of ['@', '*']) {
|
|
158
|
+
const tag = `${prefix}${myNickname.toLowerCase()}`;
|
|
159
|
+
for (let at = text.indexOf(tag); at !== -1; at = text.indexOf(tag, at + 1)) {
|
|
160
|
+
const after = text[at + tag.length];
|
|
161
|
+
if (after === undefined || !CONTINUES.test(after)) return 'you';
|
|
162
|
+
}
|
|
140
163
|
}
|
|
141
164
|
return 'nobody';
|
|
142
165
|
}
|
package/src/slack.mjs
CHANGED
|
@@ -317,6 +317,11 @@ export async function pollChannel(client, channel, { since, myNickname }) {
|
|
|
317
317
|
const history = await client.form('conversations.history', {
|
|
318
318
|
channel: channel.id,
|
|
319
319
|
limit: PAGE_LIMIT,
|
|
320
|
+
// Without this Slack returns no metadata at all, so every message
|
|
321
|
+
// another agent signed arrives looking like a bare bot post and is
|
|
322
|
+
// dropped two lines below. Humans were unaffected, which is why the
|
|
323
|
+
// log looked healthy while no peer was ever seen.
|
|
324
|
+
include_all_metadata: true,
|
|
320
325
|
...(since ? { oldest: since } : {}),
|
|
321
326
|
...(cursor ? { cursor } : {}),
|
|
322
327
|
});
|