@timqi/pier 0.1.0 → 0.1.1
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 +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
|
@@ -1,47 +1,26 @@
|
|
|
1
|
-
// How a turn becomes messages in a Slack thread
|
|
2
|
-
//
|
|
3
|
-
// Split out of the adapter because it is a separate decision from routing
|
|
4
|
-
// inbound traffic: which renderer to use, how to chunk against that renderer's
|
|
5
|
-
// limit, and what an empty turn still has to say. The adapter keeps the 👀
|
|
6
|
-
// receipts, because those are about the turn ending, not about what was said.
|
|
1
|
+
// How a turn becomes messages in a Slack thread: which renderer, how to chunk
|
|
2
|
+
// against its limit, and what an empty turn still has to say.
|
|
7
3
|
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
8
4
|
import { sendAttachments, splitAttachments } from "./attach.js";
|
|
9
5
|
import { isBlockRejection } from "./slack-api.js";
|
|
10
6
|
import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
|
|
11
|
-
/**
|
|
12
|
-
* The web shows a turn's cost on hover. Slack has a `context` block — genuinely
|
|
13
|
-
* small, muted text — so unlike Telegram the footer needs no italic hack to
|
|
14
|
-
* read as a footnote.
|
|
15
|
-
*/
|
|
16
7
|
const footerText = (meta) => escapeMrkdwn(formatTurnMeta(meta));
|
|
17
8
|
export class SlackOutbound {
|
|
18
9
|
api;
|
|
19
10
|
log;
|
|
20
|
-
/**
|
|
21
|
-
* Latched off for the process on the first refusal, so the failed round trip
|
|
22
|
-
* is paid once rather than once per message.
|
|
23
|
-
*/
|
|
11
|
+
/** Latched off on the first refusal, so the failed round trip is paid once. */
|
|
24
12
|
markdownBlocks = true;
|
|
25
13
|
constructor(api, log) {
|
|
26
14
|
this.api = api;
|
|
27
15
|
this.log = log;
|
|
28
16
|
}
|
|
29
|
-
/**
|
|
30
|
-
* Post one turn, empty text included: the turn settled with nothing to say,
|
|
31
|
-
* and that is still something to show.
|
|
32
|
-
*/
|
|
33
17
|
async reply(channel, threadTs, reply) {
|
|
34
|
-
// A file
|
|
35
|
-
// Slack: the bytes are uploaded instead and the label stays in the text.
|
|
18
|
+
// A local file link is dead in Slack: the bytes are uploaded instead.
|
|
36
19
|
const { text: spoken, paths } = splitAttachments(reply.text);
|
|
37
20
|
const text = spoken.trim();
|
|
38
21
|
const footer = reply.meta ? footerText(reply.meta) : "";
|
|
39
22
|
const row = actions(reply.suggestions);
|
|
40
|
-
//
|
|
41
|
-
// of nothing it was. Silence must be *observable*: total silence is
|
|
42
|
-
// indistinguishable from a crash, a dropped connection or a bug, and the
|
|
43
|
-
// person waiting has no way to tell. A muted one-liner is the cheapest
|
|
44
|
-
// honest answer.
|
|
23
|
+
// An empty turn still posts its footer and says which kind of nothing (§5).
|
|
45
24
|
const quiet = isSilentReply(reply)
|
|
46
25
|
? `_${quietLabel(reply.silence && escapeMrkdwn(reply.silence))}_`
|
|
47
26
|
: "";
|
|
@@ -50,33 +29,20 @@ export class SlackOutbound {
|
|
|
50
29
|
const parts = text ? chunk(text, this.budget()) : [""];
|
|
51
30
|
for (const [i, part] of parts.entries()) {
|
|
52
31
|
const last = i === parts.length - 1;
|
|
53
|
-
// The
|
|
54
|
-
// shares the footer's block, so an empty turn is one muted line rather
|
|
55
|
-
// than two.
|
|
32
|
+
// The quiet marker shares the footer's block: one muted line, not two.
|
|
56
33
|
const note = last ? [quiet, footer].filter(Boolean).join(" · ") : "";
|
|
57
34
|
await this.post(channel, threadTs, part, [
|
|
58
35
|
...(note ? [context(note)] : []),
|
|
59
36
|
...(last && row ? [row] : []),
|
|
60
37
|
]);
|
|
61
38
|
}
|
|
62
|
-
// Attachments follow the words, so the message introducing them is above
|
|
63
|
-
// them; anything that could not be sent says so in the thread.
|
|
64
39
|
const lost = await sendAttachments(paths, (file) => this.api.uploadFile(channel, threadTs, file), this.log);
|
|
65
|
-
// Unescaped, like every other body: post() escapes on the path that needs it.
|
|
66
40
|
if (lost)
|
|
67
41
|
await this.post(channel, threadTs, lost, []);
|
|
68
42
|
}
|
|
69
|
-
/**
|
|
70
|
-
*
|
|
71
|
-
* plain — no buttons and no turn footer, because the turn this input
|
|
72
|
-
* triggers has not ended yet.
|
|
73
|
-
*
|
|
74
|
-
* Answers with the `ts` of the last message it posted — where the caller
|
|
75
|
-
* puts the 👀 for that turn, at the foot of the thread the reply will land
|
|
76
|
-
* in. Undefined when there was nothing to post.
|
|
77
|
-
*/
|
|
43
|
+
/** No footer: the turn this input triggers has not ended. Answers with the
|
|
44
|
+
* `ts` of the last message posted, where the caller puts the 👀. */
|
|
78
45
|
async note(channel, threadTs, note) {
|
|
79
|
-
// Markdown's own blockquote, so the note reads as quoted on either path.
|
|
80
46
|
const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
|
|
81
47
|
let ts;
|
|
82
48
|
for (const part of chunk(`_${originLabel(note.origin)}_\n${body}`, this.budget())) {
|
|
@@ -84,22 +50,13 @@ export class SlackOutbound {
|
|
|
84
50
|
}
|
|
85
51
|
return ts;
|
|
86
52
|
}
|
|
87
|
-
/** Which budget `chunk()` should respect, given the path we are on. */
|
|
88
53
|
budget() {
|
|
89
54
|
return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
|
|
90
55
|
}
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* The `markdown` block takes the agent's markdown unmodified — tables,
|
|
95
|
-
* headers and nested lists all survive, none of which the mrkdwn subset can
|
|
96
|
-
* express — and the client never folds it behind "Show more". It is recent
|
|
97
|
-
* enough to be refused by an older workspace, so a rejection degrades to the
|
|
98
|
-
* translated mrkdwn path instead of losing the turn.
|
|
99
|
-
*/
|
|
56
|
+
/** The `markdown` block is recent enough to be refused by an older
|
|
57
|
+
* workspace; a rejection degrades to the translated mrkdwn path. */
|
|
100
58
|
async post(channel, threadTs, body, trailing) {
|
|
101
|
-
// `text` is the notification
|
|
102
|
-
// beside the blocks.
|
|
59
|
+
// `text` is the notification fallback, never shown beside the blocks.
|
|
103
60
|
const notice = body || trailing.length ? body || "…" : "";
|
|
104
61
|
if (this.markdownBlocks) {
|
|
105
62
|
const blocks = [...(body ? [markdown(body)] : []), ...trailing];
|
|
@@ -116,8 +73,7 @@ export class SlackOutbound {
|
|
|
116
73
|
this.log(`markdown block refused, falling back to mrkdwn: ${String(err)}`);
|
|
117
74
|
}
|
|
118
75
|
}
|
|
119
|
-
//
|
|
120
|
-
// was chunked against the larger budget, so it may need splitting again.
|
|
76
|
+
// The body was chunked against the larger budget, so it may split again.
|
|
121
77
|
let ts;
|
|
122
78
|
for (const part of body ? chunk(toMrkdwn(body), MRKDWN_MAX) : [""]) {
|
|
123
79
|
const blocks = [...sections(part), ...trailing];
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
// Slack's half of the settings panel
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// The modal is what makes this half smaller than Telegram's: `private_metadata`
|
|
5
|
-
// carries the conversation with the dialog, so a submitted path needs no
|
|
6
|
-
// adapter-side state to be understood, and survives a reload.
|
|
1
|
+
// Slack's half of the settings panel (panel.ts has the rest). The modal's
|
|
2
|
+
// `private_metadata` carries the conversation, so a submission needs no
|
|
3
|
+
// adapter-side state and survives a reload.
|
|
7
4
|
import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
|
|
8
5
|
import { context, escapeMrkdwn as esc, section } from "./slack-render.js";
|
|
9
|
-
/** The modal's ids. `private_metadata` carries which conversation it is for. */
|
|
10
6
|
const CWD_VIEW = "cfg_cwd";
|
|
11
7
|
const CWD_BLOCK = "cwd_block";
|
|
12
8
|
const CWD_INPUT = "cwd_input";
|
|
@@ -34,13 +30,11 @@ export class SlackPanel extends ChatPanel {
|
|
|
34
30
|
blocks(view, note) {
|
|
35
31
|
return [
|
|
36
32
|
...view.groups.map((g) => section([`*${g.title}*${g.suffix ?? ""}`, ...g.lines].join("\n"))),
|
|
37
|
-
// Slack fits a page of choices on one row; Telegram would not.
|
|
38
33
|
...(view.picks?.length ? [row(view.picks)] : []),
|
|
39
34
|
...view.rows.filter((r) => r.length).map(row),
|
|
40
35
|
...(note ? [context(esc(note))] : []),
|
|
41
36
|
];
|
|
42
37
|
}
|
|
43
|
-
/** Open a fresh panel, replacing whichever one this conversation had. */
|
|
44
38
|
async open(key, channel, threadTs) {
|
|
45
39
|
const sent = await this.deps.api.postMessage({
|
|
46
40
|
channel,
|
|
@@ -63,10 +57,7 @@ export class SlackPanel extends ChatPanel {
|
|
|
63
57
|
.catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
|
|
64
58
|
}
|
|
65
59
|
// --- actions ---------------------------------------------------------------
|
|
66
|
-
/**
|
|
67
|
-
* Handle a `cfg:` click. Returns false when the action is not ours, so the
|
|
68
|
-
* caller can treat it as one of the agent's next-step labels instead.
|
|
69
|
-
*/
|
|
60
|
+
/** Returns false when the action is not ours. */
|
|
70
61
|
async onAction(interaction, key, actionId) {
|
|
71
62
|
return this.dispatch(key, actionId, interaction, async () => {
|
|
72
63
|
const channel = interaction.channel?.id;
|
|
@@ -1,78 +1,44 @@
|
|
|
1
1
|
// How a reply looks on Slack: mrkdwn text, and buttons as a Block Kit
|
|
2
|
-
// `actions` row.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// strikethrough is `~one~` tilde, and a link is `<url|label>` — so the agent's
|
|
6
|
-
// markdown has to be translated, not passed through. Only `&`, `<` and `>` are
|
|
7
|
-
// escaped; unlike Telegram's HTML parser Slack degrades unknown syntax to
|
|
8
|
-
// literal text instead of rejecting the message, so the risk here is an ugly
|
|
9
|
-
// reply rather than a lost one.
|
|
2
|
+
// `actions` row. mrkdwn is not markdown (`*bold*`, `_italic_`, `~strike~`,
|
|
3
|
+
// `<url|label>`), so the agent's markdown is translated; Slack degrades unknown
|
|
4
|
+
// syntax to literal text rather than rejecting the message.
|
|
10
5
|
import { balanceFences, chunkText } from "./chunk.js";
|
|
11
|
-
/**
|
|
12
|
-
* A `markdown` block's budget: Slack caps them at 12,000 cumulative chars per
|
|
13
|
-
* message, and one message carries one. This is the normal path.
|
|
14
|
-
*/
|
|
6
|
+
/** Slack caps `markdown` blocks at 12,000 cumulative chars per message. */
|
|
15
7
|
export const MARKDOWN_MAX = 11_000;
|
|
16
|
-
/**
|
|
17
|
-
* The legacy fallback's budget: a `section` block's text caps at 3000, and the
|
|
18
|
-
* mrkdwn translation adds a little markup.
|
|
19
|
-
*/
|
|
8
|
+
/** A `section` block's text caps at 3000, and the mrkdwn translation adds markup. */
|
|
20
9
|
export const MRKDWN_MAX = 2800;
|
|
21
10
|
// Slack truncates a button label past this, mid-word.
|
|
22
11
|
const BUTTON_MAX = 75;
|
|
23
|
-
/** Shared: the adapter and the panel escape plain text with this too. */
|
|
24
12
|
export const escapeMrkdwn = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* Bold is marked with a private-use sentinel rather than written as `*` right
|
|
29
|
-
* away: mrkdwn spells bold with the single star that markdown uses for italic,
|
|
30
|
-
* so emitting it early would let the italic pass eat it again.
|
|
31
|
-
*/
|
|
13
|
+
/** mrkdwn spells bold with the single star markdown uses for italic, so bold
|
|
14
|
+
* is marked with a sentinel until the italic pass has run. */
|
|
32
15
|
const BOLD = "\uE002";
|
|
33
16
|
function inline(text) {
|
|
34
17
|
return text
|
|
35
|
-
// Links first: their label may itself carry emphasis.
|
|
36
|
-
// order of markdown's pair, and `>` inside is already escaped.
|
|
18
|
+
// Links first: their label may itself carry emphasis.
|
|
37
19
|
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => `<${url}|${label}>`)
|
|
38
20
|
// Bold before italic: `**x**` must not be seen as two `*x*` runs.
|
|
39
21
|
.replace(/\*\*([^\n*]+)\*\*/g, `${BOLD}$1${BOLD}`)
|
|
40
22
|
.replace(/~~([^\n~]+)~~/g, "~$1~")
|
|
41
23
|
.replace(/(^|[\s(])[*_]([^\n*_]+)[*_](?=[\s).,!?:;]|$)/g, "$1_$2_")
|
|
42
|
-
// Headings have no size in Slack either; bold is the closest honest render.
|
|
43
24
|
.replace(/^#{1,6}[ \t]+(.+)$/gm, `${BOLD}$1${BOLD}`)
|
|
44
|
-
// Slack renders neither `-` nor `*` as a list marker
|
|
25
|
+
// Slack renders neither `-` nor `*` as a list marker.
|
|
45
26
|
.replace(/^[ \t]*[-*+][ \t]+/gm, "\u2022 ")
|
|
46
27
|
.replaceAll(BOLD, "*");
|
|
47
28
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Render one assistant turn as mrkdwn. Code spans and fences are extracted
|
|
50
|
-
* before escaping so emphasis inside them stays literal.
|
|
51
|
-
*/
|
|
29
|
+
/** Code is stashed before escaping so emphasis inside it stays literal. */
|
|
52
30
|
export function toMrkdwn(markdown) {
|
|
53
31
|
const stash = [];
|
|
54
|
-
// Private-use sentinels: markdown cannot contain them, so a stashed block
|
|
55
|
-
// cannot be re-matched by the escaping and emphasis passes that follow.
|
|
56
32
|
const keep = (text) => `\uE000${stash.push(text) - 1}\uE001`;
|
|
57
|
-
// Slack
|
|
58
|
-
// shown as the first line of the block.
|
|
33
|
+
// Slack fences carry no language; the hint would show as the first line.
|
|
59
34
|
let out = markdown.replace(/```[\w.+-]*\n?([\s\S]*?)```/g, (_m, code) => keep("```\n" + escapeMrkdwn(code.replace(/\n+$/, "")) + "\n```"));
|
|
60
35
|
out = out.replace(/`([^`\n]+)`/g, (_m, code) => keep(`\`${escapeMrkdwn(code)}\``));
|
|
61
36
|
out = inline(escapeMrkdwn(out));
|
|
62
37
|
return out.replace(/\uE000(\d+)\uE001/g, (_m, i) => stash[Number(i)] ?? "");
|
|
63
38
|
}
|
|
64
|
-
/**
|
|
65
|
-
* Split rendered mrkdwn into sendable chunks at the last blank line or newline
|
|
66
|
-
* that fits, then re-balance code fences across the cut (see chunk.ts for why
|
|
67
|
-
* an unbalanced fence is a mangled reply here and not on Telegram).
|
|
68
|
-
*/
|
|
69
39
|
export const chunk = (text, max) => balanceFences(chunkText(text, max));
|
|
70
|
-
/**
|
|
71
|
-
*
|
|
72
|
-
* `section` for everything the agent wrote: it takes the markdown unmodified
|
|
73
|
-
* (so tables and headers survive) and the client never folds it behind
|
|
74
|
-
* "Show more".
|
|
75
|
-
*/
|
|
40
|
+
/** Slack's own renderer: tables and headers survive, and the client never
|
|
41
|
+
* folds it behind "Show more". */
|
|
76
42
|
export const markdown = (text) => ({ type: "markdown", text });
|
|
77
43
|
export const section = (text) => ({
|
|
78
44
|
type: "section",
|
|
@@ -80,14 +46,9 @@ export const section = (text) => ({
|
|
|
80
46
|
});
|
|
81
47
|
// Slack caps a message at 50 blocks; the footer and the button row need two.
|
|
82
48
|
const MAX_BLOCKS = 45;
|
|
83
|
-
// A section block's hard limit
|
|
84
|
-
// message below this — but the overflow merge below could in principle.
|
|
49
|
+
// A section block's hard limit; only the overflow merge in sections() can reach it.
|
|
85
50
|
const SECTION_MAX = 2900;
|
|
86
|
-
/**
|
|
87
|
-
* Split rendered mrkdwn into paragraphs without ever cutting a fenced code
|
|
88
|
-
* block — a fence split across two blocks would leave both unbalanced, the
|
|
89
|
-
* same hazard `chunk()` handles for messages.
|
|
90
|
-
*/
|
|
51
|
+
/** Never cuts a fenced block: a fence split across two blocks leaves both unbalanced. */
|
|
91
52
|
function paragraphs(text) {
|
|
92
53
|
const out = [];
|
|
93
54
|
let buf = [];
|
|
@@ -99,7 +60,6 @@ function paragraphs(text) {
|
|
|
99
60
|
};
|
|
100
61
|
for (const line of text.split("\n")) {
|
|
101
62
|
const isFence = line.trimStart().startsWith("```");
|
|
102
|
-
// A blank line only ends a paragraph outside a fence; inside one it is code.
|
|
103
63
|
if (!fenced && !isFence && !line.trim()) {
|
|
104
64
|
flush();
|
|
105
65
|
continue;
|
|
@@ -107,7 +67,6 @@ function paragraphs(text) {
|
|
|
107
67
|
buf.push(line);
|
|
108
68
|
if (isFence) {
|
|
109
69
|
fenced = !fenced;
|
|
110
|
-
// A closed fence stands alone, so it can never be merged apart.
|
|
111
70
|
if (!fenced)
|
|
112
71
|
flush();
|
|
113
72
|
}
|
|
@@ -115,52 +74,31 @@ function paragraphs(text) {
|
|
|
115
74
|
flush();
|
|
116
75
|
return out;
|
|
117
76
|
}
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* A whole turn in a single section block gets collapsed behind "Show more",
|
|
123
|
-
* hiding most of the answer; several blocks render unfolded. Paragraphs are
|
|
124
|
-
* deliberately *not* packed together to fill a size budget — a paragraph is
|
|
125
|
-
* already the natural short unit, and merging a few of them back into one tall
|
|
126
|
-
* block is exactly what brings the collapse back.
|
|
127
|
-
*/
|
|
77
|
+
/** The fallback for a workspace that refuses the `markdown` block. One section
|
|
78
|
+
* per paragraph, never packed: a tall single block is collapsed behind "Show
|
|
79
|
+
* more", several short ones render unfolded. */
|
|
128
80
|
export function sections(text) {
|
|
129
81
|
const paras = paragraphs(text);
|
|
130
82
|
if (!paras.length)
|
|
131
83
|
return [];
|
|
132
|
-
//
|
|
133
|
-
// dropped: a truncated reply is worse than a tall one, and silently losing
|
|
134
|
-
// the end of an answer is worst of all.
|
|
84
|
+
// The tail is folded into the last block, not dropped.
|
|
135
85
|
const kept = paras.slice(0, MAX_BLOCKS - 1);
|
|
136
86
|
const tail = paras.slice(MAX_BLOCKS - 1);
|
|
137
87
|
if (tail.length)
|
|
138
88
|
kept.push(tail.join("\n\n").slice(0, SECTION_MAX));
|
|
139
89
|
return kept.map(section);
|
|
140
90
|
}
|
|
141
|
-
/**
|
|
142
|
-
* Slack's small muted text. Telegram has none, which is why `formatTurnMeta`
|
|
143
|
-
* lands there as an italic footnote; here the footer gets the block the
|
|
144
|
-
* platform actually has for it.
|
|
145
|
-
*/
|
|
91
|
+
/** Slack's small muted text, for the footer. */
|
|
146
92
|
export const context = (text) => ({
|
|
147
93
|
type: "context",
|
|
148
94
|
elements: [{ type: "mrkdwn", text }],
|
|
149
95
|
});
|
|
150
96
|
// --- next-step buttons -------------------------------------------------------
|
|
151
|
-
/**
|
|
152
|
-
*
|
|
153
|
-
* `value`, but the index is what makes a button survive a `runtime.reload()`:
|
|
154
|
-
* the label is read back off the message Slack echoes with the click, so no
|
|
155
|
-
* adapter-instance memory is involved.
|
|
156
|
-
*/
|
|
97
|
+
/** `action_id` carries an index: the label is read back off the message Slack
|
|
98
|
+
* echoes with the click, so a button survives a reload. */
|
|
157
99
|
export const OFFER_PREFIX = "sg:";
|
|
158
100
|
const truncate = (label) => label.length > BUTTON_MAX ? `${label.slice(0, BUTTON_MAX - 1)}\u2026` : label;
|
|
159
|
-
/**
|
|
160
|
-
* One actions row. Slack wraps buttons on its own and gives each its natural
|
|
161
|
-
* width, so unlike Telegram there is no row packing to budget — a long label
|
|
162
|
-
* beside a short one costs nothing.
|
|
163
|
-
*/
|
|
101
|
+
/** Slack wraps buttons on its own, so there is no row packing to budget. */
|
|
164
102
|
export function actions(labels) {
|
|
165
103
|
if (!labels.length)
|
|
166
104
|
return undefined;
|
|
@@ -171,12 +109,6 @@ export function actions(labels) {
|
|
|
171
109
|
}));
|
|
172
110
|
return { type: "actions", elements };
|
|
173
111
|
}
|
|
174
|
-
/**
|
|
175
|
-
* The label a next-step `action_id` stands for, read off the clicked message's
|
|
176
|
-
* own blocks — which Slack echoes back in the interaction payload. A button
|
|
177
|
-
* therefore keeps working across a restart or a config reload, where an
|
|
178
|
-
* in-memory offer list would not.
|
|
179
|
-
*/
|
|
180
112
|
export function offeredLabel(blocks, actionId) {
|
|
181
113
|
if (!actionId.startsWith(OFFER_PREFIX))
|
|
182
114
|
return undefined;
|