@stratusagent/channel-slack 0.11.0 → 0.11.2
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 +107 -2
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +762 -53
- package/dist/index.js.map +1 -1
- package/manifest/stratus-agent.manifest.json +7 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ Slack adapter for Stratus agents. **One Slack app per agent** — Slack has no w
|
|
|
4
4
|
|
|
5
5
|
- **Resumable conversations**: session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>` (DMs: the DM channel id) — a thread is a conversation, it survives daemon restarts, and two agents sharing a thread keep fully separate sessions. A turn parked on a human when the daemon died is re-asked after the restart, and when it finishes its reply is posted into the thread as a fresh message — the placeholder it would have edited belonged to the old process.
|
|
6
6
|
- **Streaming replies**: post a placeholder, edit as deltas arrive (throttled for `chat.update` limits), show `⚙ tool…` status lines, finalize with the full reply — split across messages when it outgrows one.
|
|
7
|
-
- **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. Code spans and fences are left exactly as written, an unclosed fence included, so a `**` inside a snippet stays part of the snippet — a run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work. One or two unmatched backticks are literal text, as they are in Markdown. Anything whose spelling already matches — lists, block quotes, inline code — is untouched. A marker still being streamed stays literal until its closing half arrives.
|
|
8
|
-
- **Mention
|
|
7
|
+
- **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings — unless the heading already carries an asterisk of its own, which is left as it is: Slack has one bold delimiter and no way to nest it, so a second pair would print rather than render. Code spans and fences are left exactly as written, an unclosed fence included, so a `**` inside a snippet stays part of the snippet — a run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work. One or two unmatched backticks are literal text, as they are in Markdown. Anything whose spelling already matches — lists, block quotes, inline code — is untouched. A marker still being streamed stays literal until its closing half arrives.
|
|
8
|
+
- **Mention to start, then it stays in the thread.** A mention opens a conversation; every reply in that thread reaches the agent without tagging it again, and free-form DMs are unchanged. Who an untagged reply is for is decided by [Who a message is for](#who-a-message-is-for). Inbound mentions of other users are humanized to `@Display Name` for the model; redeliveries are deduped so a slow turn never runs twice.
|
|
9
9
|
- **Approval buttons** for the gateway's `remote` permission mode: a gated tool call parks the turn and asks in its thread with **Allow once** / **Always allow** / **Deny**. See [Approving tool calls](#approving-tool-calls) — clicks are authorized by *who clicked*, never by who can see the message, and **Always allow** is offered only where the daemon would actually remember it.
|
|
10
10
|
- **Tokens are gateway infrastructure secrets**, stored in the `channels` namespace of `~/.stratus/credentials.json` — never in an agent's credential allowlist:
|
|
11
11
|
|
|
@@ -19,6 +19,67 @@ Slack adapter for Stratus agents. **One Slack app per agent** — Slack has no w
|
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
## Who a message is for
|
|
23
|
+
|
|
24
|
+
A mention is how a conversation starts. After that, the thread is the
|
|
25
|
+
address — replying in it reaches the agent the way replying to a colleague
|
|
26
|
+
reaches them, with no `@` and no ceremony. Four rules decide the rest, and
|
|
27
|
+
they are the whole model:
|
|
28
|
+
|
|
29
|
+
1. **A mention is always for whoever is named.** In a thread or out of one,
|
|
30
|
+
naming an agent hands it the question.
|
|
31
|
+
2. **Inside a thread it is already in, an agent listens without being
|
|
32
|
+
named.** "Already in" means a session exists under that thread's key —
|
|
33
|
+
which happens only because somebody mentioned it there — so an agent
|
|
34
|
+
follows conversations it was brought into and nothing else. That record
|
|
35
|
+
is durable, so a daemon restart mid-thread forgets nothing.
|
|
36
|
+
3. **The room is not a conversation.** A channel message outside any thread
|
|
37
|
+
is never a follow-up, however much of the channel the app can see. An
|
|
38
|
+
agent that has not been spoken to does not join in.
|
|
39
|
+
4. **In a thread with several agents, an untagged reply goes to whoever
|
|
40
|
+
spoke last.** The same rule people use: you are answering the voice that
|
|
41
|
+
just answered you. Naming another agent moves the conversation to them —
|
|
42
|
+
so a handover is one `@`, and it takes effect the moment it is sent, not
|
|
43
|
+
whenever the turn it starts gets around to answering: an addendum typed
|
|
44
|
+
straight after it reaches the agent you just named, and the agent that
|
|
45
|
+
*had* the thread stands down at the same instant rather than whenever its
|
|
46
|
+
own app next catches up.
|
|
47
|
+
|
|
48
|
+
Everyone in the thread is talking to the same agent — a reply from a second
|
|
49
|
+
person is a follow-up like any other, and channel messages reach the model
|
|
50
|
+
prefixed with the speaker's display name so it knows who said what. A reply
|
|
51
|
+
carrying a file, or one the author also broadcast to the channel, is a
|
|
52
|
+
follow-up too; what Slack marks as bookkeeping — an edit, a deletion, a
|
|
53
|
+
join — is not, and nothing answers it. **Attachment contents are not
|
|
54
|
+
readable** — the app does not ask for `files:read` — so a message with files
|
|
55
|
+
reaches the agent naming them and saying they cannot be opened, which is
|
|
56
|
+
what lets it answer honestly instead of as though it had read the log. A
|
|
57
|
+
file dropped in with nothing said is not a question, and gets no reply. What is
|
|
58
|
+
*not* shared is history: sessions are per agent, so an agent tagged into a
|
|
59
|
+
thread halfway through starts from what it is told then, not from what the
|
|
60
|
+
other agent was told. Bring it up to speed in the message that tags it.
|
|
61
|
+
|
|
62
|
+
Three edges worth knowing. An agent whose app was installed before the
|
|
63
|
+
history scopes below is told about mentions only, and behaves exactly as it
|
|
64
|
+
always did — the workspace's grant is the switch, per app. Where a thread's
|
|
65
|
+
several agents cannot be ordered — a host whose session routing carries no
|
|
66
|
+
timestamps — an untagged reply is left alone rather than answered twice;
|
|
67
|
+
mention the one you want.
|
|
68
|
+
|
|
69
|
+
A named agent whose app is down answers nothing, and nothing answers in its
|
|
70
|
+
place: being tagged is a decision about who is being asked, and an agent
|
|
71
|
+
that has been handed the question elsewhere does not take it back because
|
|
72
|
+
the other one is offline. That silence is the same signal a mention has
|
|
73
|
+
always given when an app is down.
|
|
74
|
+
|
|
75
|
+
And the rules above are mechanical, which shows in a thread where people are
|
|
76
|
+
mostly talking to *each other*: an agent invited into one answers every
|
|
77
|
+
untagged reply in it, including the ones meant for somebody else. Give the
|
|
78
|
+
side conversation its own thread. Teaching an agent to read the room
|
|
79
|
+
instead — to follow a thread whether or not it is being spoken to, and
|
|
80
|
+
answer only when it has something to add — is
|
|
81
|
+
[roadmap step 31](../../docs/roadmap/31-reading-the-room.md).
|
|
82
|
+
|
|
22
83
|
## Installing
|
|
23
84
|
|
|
24
85
|
This package is an **optional peer** of the CLI — `stratus` ships without any
|
|
@@ -43,6 +104,7 @@ editing `credentials.json` by hand.
|
|
|
43
104
|
The manual equivalent, if you prefer:
|
|
44
105
|
|
|
45
106
|
1. https://api.slack.com/apps → **Create New App → From a manifest** → paste `manifest/stratus-agent.manifest.json` with `NAME` replaced by the agent's name.
|
|
107
|
+
The manifest asks for the `channels:history` / `groups:history` / `mpim:history` scopes and the matching `message.*` events, which is what lets an agent [stay in a thread](#who-a-message-is-for) instead of needing a mention every time. An app created before those shipped needs them added under **OAuth & Permissions** and **Event Subscriptions** and reinstalled once; leave them off and it answers mentions and DMs, exactly as it did before.
|
|
46
108
|
2. **Basic Information → App-Level Tokens** → generate a token with `connections:write` (that's the `appToken`, `xapp-…`).
|
|
47
109
|
3. **Install App** to the workspace → copy the **Bot User OAuth Token** (that's the `botToken`, `xoxb-…`).
|
|
48
110
|
4. Upload the agent's avatar under **Display Information**.
|
|
@@ -132,6 +194,49 @@ needs `settings.interactivity.is_enabled` set to `true` once, under **App
|
|
|
132
194
|
Manifest**. No request URL is needed — the clicks arrive over the same Socket
|
|
133
195
|
Mode connection.
|
|
134
196
|
|
|
197
|
+
## Who counts as the operator
|
|
198
|
+
|
|
199
|
+
Everything above decides *which agent* a message is for. None of it decides
|
|
200
|
+
*who is speaking*: the adapter admits a message that has a user, is not a
|
|
201
|
+
bot, carries no bookkeeping subtype, and is a DM or a mention — and any
|
|
202
|
+
member of the workspace can open a DM or type an `@mention`. A DM proves
|
|
203
|
+
nothing about who is typing.
|
|
204
|
+
|
|
205
|
+
So a message is the operator's only when the operator has said so:
|
|
206
|
+
|
|
207
|
+
```jsonc
|
|
208
|
+
{
|
|
209
|
+
"principals": {
|
|
210
|
+
"slackUsers": ["U01DYLAN"],
|
|
211
|
+
"agents": {
|
|
212
|
+
"ava": { "slackUsers": ["U01DYLAN", "U01OPS"] },
|
|
213
|
+
"bea": { "slackUsers": [] }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
A turn from a listed id reaches the agent as its operator's (`user`). A turn
|
|
220
|
+
from anyone else — in a DM as much as in a channel — reaches it as
|
|
221
|
+
`unknown`, and the session it lands in stays `unknown` from then on: that
|
|
222
|
+
text is in the transcript. Every fact the agent remembers afterwards
|
|
223
|
+
carries the label, and the prompt renders it under a heading that says so.
|
|
224
|
+
With no list at all, every Slack sender is `unknown`, and `stratus serve`
|
|
225
|
+
says so at startup. An agent's own entry replaces the shared list;
|
|
226
|
+
`"slackUsers": []` excludes an agent from it.
|
|
227
|
+
|
|
228
|
+
The sender is judged on **every message**, not once per thread. A thread
|
|
229
|
+
keys one session for everyone in it, so an authorized member can open one
|
|
230
|
+
and a stranger can mention the agent inside it afterwards — the stranger's
|
|
231
|
+
turn lowers the session, and the authorized member's next turn does not
|
|
232
|
+
raise it back.
|
|
233
|
+
|
|
234
|
+
Like `approvals`, this block is read only from a config you chose —
|
|
235
|
+
`--config`, `STRATUS_CONFIG`, or the global `~/.stratus/config.json`. A
|
|
236
|
+
project-local `stratus.config.json` cannot appoint itself the principal.
|
|
237
|
+
The labels themselves are documented in
|
|
238
|
+
[Memory](../../docs/concepts/memory.md#where-a-fact-came-from).
|
|
239
|
+
|
|
135
240
|
## Speaking first: the outbound seam
|
|
136
241
|
|
|
137
242
|
The adapter also implements the channel contract's `resolveOutbound` — how a
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,17 @@ export interface SlackAgentConfig {
|
|
|
20
20
|
* the answer already is.
|
|
21
21
|
*/
|
|
22
22
|
approvalChannel?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Slack user ids whose messages are the operator's: a turn from one of
|
|
25
|
+
* them arrives as `user`, a turn from anyone else as `unknown`. The
|
|
26
|
+
* adapter's own admission checks — has a user, is not a bot, is a DM or
|
|
27
|
+
* a mention — establish nothing about who is typing, since any member of
|
|
28
|
+
* the workspace can open a DM or type an `@mention`; this list is what
|
|
29
|
+
* makes `user` mean something. Evaluated on every message, because a
|
|
30
|
+
* thread keys one session for everyone in it. Empty or absent means
|
|
31
|
+
* nobody is, and every sender is `unknown`.
|
|
32
|
+
*/
|
|
33
|
+
principals?: string[];
|
|
23
34
|
}
|
|
24
35
|
export interface SlackSocketEventArgs {
|
|
25
36
|
ack: (response?: unknown) => Promise<void>;
|
|
@@ -67,6 +78,16 @@ export interface SlackInboundEvent {
|
|
|
67
78
|
channel_type?: string;
|
|
68
79
|
bot_id?: string;
|
|
69
80
|
subtype?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Attachments on a `file_share` message. Slack puts the file metadata in
|
|
83
|
+
* the event and the bytes behind an authenticated URL that needs
|
|
84
|
+
* `files:read`, which this app does not ask for — so a turn learns that a
|
|
85
|
+
* file arrived and what it is called, never what is in it.
|
|
86
|
+
*/
|
|
87
|
+
files?: Array<{
|
|
88
|
+
name?: string;
|
|
89
|
+
title?: string;
|
|
90
|
+
}>;
|
|
70
91
|
}
|
|
71
92
|
export interface SlackSocketLike {
|
|
72
93
|
on(eventName: string, listener: (args: SlackSocketEventArgs) => void): void;
|
|
@@ -168,6 +189,14 @@ export interface SlackAdapterOptions {
|
|
|
168
189
|
* (DMs: the DM channel id alone), so threads are resumable conversations
|
|
169
190
|
* and two agents sharing a thread keep fully separate sessions. Replies
|
|
170
191
|
* stream via placeholder-then-edit, throttled for chat.update limits.
|
|
192
|
+
*
|
|
193
|
+
* A mention starts a thread conversation; inside one, the agent keeps
|
|
194
|
+
* listening without being tagged again — see `couldBeFollowUp` and
|
|
195
|
+
* `answersFollowUp` for exactly whose reply an untagged message is. That
|
|
196
|
+
* needs the app to be subscribed to `message.channels` / `message.groups`
|
|
197
|
+
* / `message.mpim` (the shipped manifest is); an app installed before
|
|
198
|
+
* those were in it receives only `app_mention` and behaves as it always
|
|
199
|
+
* did, which makes the workspace's own grant the switch.
|
|
171
200
|
*/
|
|
172
201
|
export declare const createSlackChannelAdapter: (options: SlackAdapterOptions) => ChannelAdapter;
|
|
173
202
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|