@grknbyk/agent-wire 0.4.1 → 0.6.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 +110 -24
- package/bin/agent-wire.mjs +148 -115
- package/manifest.json +31 -38
- package/package.json +3 -2
- package/src/config.mjs +182 -68
- package/src/drain.mjs +86 -0
- package/src/identity.mjs +101 -73
- package/src/inbox.mjs +107 -76
- package/src/mcp.mjs +382 -309
- package/src/protocol.mjs +77 -68
- package/src/setup.mjs +171 -262
- package/src/slack.mjs +322 -236
- package/src/status.mjs +9 -4
package/src/inbox.mjs
CHANGED
|
@@ -1,76 +1,107 @@
|
|
|
1
|
-
// The local log is the source of truth, not the Slack channel. Slack history is a
|
|
2
|
-
// cache we can re-read at any time, so a full re-scan is an ordinary idempotent
|
|
3
|
-
// operation rather than a recovery procedure.
|
|
4
|
-
//
|
|
5
|
-
// inbox.jsonl is append-only and keyed by the Slack timestamp, which is unique per
|
|
6
|
-
// channel and survives a re-install. Message state lives in a separate file so the
|
|
7
|
-
// append-only log never has to be rewritten in place.
|
|
8
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
-
|
|
10
|
-
import { HOME, paths,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
export
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
1
|
+
// The local log is the source of truth, not the Slack channel. Slack history is a
|
|
2
|
+
// cache we can re-read at any time, so a full re-scan is an ordinary idempotent
|
|
3
|
+
// operation rather than a recovery procedure.
|
|
4
|
+
//
|
|
5
|
+
// inbox.jsonl is append-only and keyed by the Slack timestamp, which is unique per
|
|
6
|
+
// channel and survives a re-install. Message state lives in a separate file so the
|
|
7
|
+
// append-only log never has to be rewritten in place.
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
import { HOME, derivedFromFile, paths, readJsonCached, scopeId, writeJson } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
// Enough to catch up on a conversation, short enough not to bury the session that
|
|
13
|
+
// asked. A caller that wants the whole log passes its own count.
|
|
14
|
+
export const DEFAULT_COUNT = 20;
|
|
15
|
+
|
|
16
|
+
// Two keys, because the log and the reading of it have different owners. The log
|
|
17
|
+
// is shared: one poller writes one copy of each message, and this is what stops it
|
|
18
|
+
// writing a second.
|
|
19
|
+
const logKey = (item) => `${item.channel}:${item.ts}`;
|
|
20
|
+
|
|
21
|
+
// Read and unread are per session, because the modes are. One session set to
|
|
22
|
+
// `read` opens everything it is given; if that also marked the message read for
|
|
23
|
+
// the session next door, an `ask` session would report an empty inbox forever.
|
|
24
|
+
const storageKey = (item) => `${scopeId()}|${logKey(item)}`;
|
|
25
|
+
|
|
26
|
+
// Parsed at most once per write. Four call sites read the whole log — select,
|
|
27
|
+
// append, archive, findByTs — and a poll runs several of them back to back, so
|
|
28
|
+
// without this a 20k-message log is parsed four times to answer one question.
|
|
29
|
+
export function readInbox() {
|
|
30
|
+
if (!existsSync(paths.inbox)) return [];
|
|
31
|
+
return derivedFromFile(paths.inbox, 'parsed', () => readFileSync(paths.inbox, 'utf8')
|
|
32
|
+
.split('\n').filter(Boolean)
|
|
33
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } })
|
|
34
|
+
.filter(Boolean));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The dedup check is a lookup, so it is stored as one. Rebuilding a 20k-entry Set
|
|
38
|
+
// per appended message is the whole cost of appending a message.
|
|
39
|
+
const inboxKeys = () => derivedFromFile(paths.inbox, 'keys', () => new Set(readInbox().map(logKey)));
|
|
40
|
+
|
|
41
|
+
export const stateOf = (states, item) => states[storageKey(item)] ?? 'unread';
|
|
42
|
+
|
|
43
|
+
// The Slack timestamp is the idempotency key: a retried poll, an overlapping
|
|
44
|
+
// window, or a re-installed app all replay the same ts, and a duplicate the human
|
|
45
|
+
// has to clean up by hand is the failure that generates support noise.
|
|
46
|
+
export function appendMessages(items) {
|
|
47
|
+
if (items.length === 0) return 0;
|
|
48
|
+
|
|
49
|
+
const seen = inboxKeys();
|
|
50
|
+
const fresh = items.filter((item) => !seen.has(logKey(item)));
|
|
51
|
+
if (fresh.length === 0) return 0;
|
|
52
|
+
|
|
53
|
+
mkdirSync(HOME, { recursive: true });
|
|
54
|
+
appendFileSync(paths.inbox, fresh.map((item) => JSON.stringify(item)).join('\n') + '\n');
|
|
55
|
+
return fresh.length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `channel` names one channel explicitly and overrides everything. `channels`
|
|
59
|
+
// is the caller's allow-list, which is how switched-off channels stay out of the
|
|
60
|
+
// default view without being deleted from the log.
|
|
61
|
+
export function selectMessages({ state = 'unread', count = DEFAULT_COUNT, channel = null, channels = null } = {}) {
|
|
62
|
+
const states = readJsonCached(paths.states, {});
|
|
63
|
+
const isVisible = (item) => {
|
|
64
|
+
if (channel) return item.channel === channel;
|
|
65
|
+
if (channels) return channels.includes(item.channel);
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Scanned from the newest end and stopped at `count`. Filtering the whole log
|
|
70
|
+
// to keep the last twenty of it was most of what reading an inbox cost, and
|
|
71
|
+
// the log only grows.
|
|
72
|
+
const log = readInbox();
|
|
73
|
+
const picked = [];
|
|
74
|
+
for (let index = log.length - 1; index >= 0 && picked.length < count; index--) {
|
|
75
|
+
const item = log[index];
|
|
76
|
+
if (!isVisible(item)) continue;
|
|
77
|
+
if (state !== 'all' && stateOf(states, item) !== state) continue;
|
|
78
|
+
picked.push(item);
|
|
79
|
+
}
|
|
80
|
+
return picked.reverse();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function markRead(items) {
|
|
84
|
+
const states = readJsonCached(paths.states, {});
|
|
85
|
+
for (const item of items) states[storageKey(item)] = 'read';
|
|
86
|
+
writeJson(paths.states, states);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function archive(ts) {
|
|
90
|
+
const states = readJsonCached(paths.states, {});
|
|
91
|
+
const targets = ts
|
|
92
|
+
? readInbox().filter((item) => item.ts === ts)
|
|
93
|
+
: readInbox().filter((item) => stateOf(states, item) === 'read');
|
|
94
|
+
for (const item of targets) states[storageKey(item)] = 'archived';
|
|
95
|
+
writeJson(paths.states, states);
|
|
96
|
+
return targets.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const findByTs = (ts) => readInbox().find((item) => item.ts === ts) ?? null;
|
|
100
|
+
|
|
101
|
+
export const readCursor = (channelId) => readJsonCached(paths.cursors, {})[channelId] ?? null;
|
|
102
|
+
|
|
103
|
+
export function writeCursor(channelId, ts) {
|
|
104
|
+
const cursors = readJsonCached(paths.cursors, {});
|
|
105
|
+
cursors[channelId] = ts;
|
|
106
|
+
writeJson(paths.cursors, cursors);
|
|
107
|
+
}
|