@timqi/pier 0.1.3 → 0.1.5
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 +12 -7
- package/dist/agent/config.js +9 -3
- package/dist/agent/events.js +13 -5
- package/dist/agent/listing.js +10 -2
- package/dist/agent/packages.js +4 -20
- package/dist/agent/pi.js +18 -86
- package/dist/channels/attach.js +2 -2
- package/dist/channels/commands.js +10 -8
- package/dist/channels/config.js +32 -24
- package/dist/channels/control.js +60 -14
- package/dist/channels/conversations.js +60 -13
- package/dist/channels/handoff.js +90 -0
- package/dist/channels/lark-api.js +7 -0
- package/dist/channels/lark-outbound.js +19 -0
- package/dist/channels/lark-panel.js +41 -26
- package/dist/channels/lark-render.js +2 -1
- package/dist/channels/lark.js +35 -26
- package/dist/channels/lines.js +1 -1
- package/dist/channels/panel.js +324 -115
- package/dist/channels/receipts.js +25 -12
- package/dist/channels/routes.js +21 -7
- package/dist/channels/runtime.js +21 -7
- package/dist/channels/slack-api.js +19 -10
- package/dist/channels/slack-cli.js +503 -0
- package/dist/channels/slack-directory.js +2 -2
- package/dist/channels/slack-outbound.js +11 -1
- package/dist/channels/slack-panel.js +81 -40
- package/dist/channels/slack-render.js +3 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +38 -31
- package/dist/channels/types.js +1 -3
- package/dist/cli.js +166 -7
- package/dist/core/identity.js +61 -17
- package/dist/core/reply.js +19 -15
- package/dist/core/router.js +23 -3
- package/dist/db.js +33 -0
- package/dist/main.js +61 -41
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/settings.js +3 -12
- package/dist/socket.js +99 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +225 -0
- package/dist/tasks/definitions.js +3 -3
- package/dist/tasks/execution.js +1 -3
- package/dist/tasks/groups.js +2 -5
- package/dist/tasks/messages.js +18 -158
- package/dist/tasks/operations.js +335 -0
- package/dist/tasks/routes.js +1 -11
- package/dist/tasks/runs.js +5 -20
- package/dist/tasks/service.js +18 -43
- package/dist/tasks/store.js +15 -15
- package/dist/tools.js +18 -3
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
- package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
- package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
- package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
- package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
- package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
- package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
- package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
- package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css +2 -0
- package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
- package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
- package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
- package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
- package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
- package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
- package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
- package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
- package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/push.js +4 -10
- package/dist/web/vault.js +68 -0
- package/dist/{extensions/web → websearch}/artifacts.js +2 -2
- package/dist/websearch/cli.js +73 -0
- package/dist/websearch/run.js +273 -0
- package/docs/deploy.md +34 -11
- package/package.json +2 -3
- package/skills/pier-help/SKILL.md +32 -16
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +60 -148
- package/skills/pier-vault/SKILL.md +36 -0
- package/skills/pier-web/SKILL.md +50 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/channels/telegram-api.js +0 -86
- package/dist/channels/telegram-panel.js +0 -97
- package/dist/channels/telegram-render.js +0 -69
- package/dist/channels/telegram.js +0 -421
- package/dist/extensions/index.js +0 -10
- package/dist/extensions/web/index.js +0 -9
- package/dist/extensions/web/tools.js +0 -265
- package/dist/tasks/tool.js +0 -416
- package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
- package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
- package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
- package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
- package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
- /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
- /package/dist/{extensions/web → websearch}/content.js +0 -0
- /package/dist/{extensions/web → websearch}/http.js +0 -0
- /package/dist/{extensions/web → websearch}/json.js +0 -0
- /package/dist/{extensions/web → websearch}/language.js +0 -0
- /package/dist/{extensions/web → websearch}/openai.js +0 -0
- /package/dist/{extensions/web → websearch}/provider.js +0 -0
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
// Slack's half of the settings panel (panel.ts has the rest).
|
|
2
|
-
// `
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import {
|
|
1
|
+
// Slack's half of the settings panel (panel.ts has the rest). Every button's
|
|
2
|
+
// `value` and the modal's `private_metadata` carry the draft with the card's
|
|
3
|
+
// coordinates, so a click or a submission after a reload is understood from
|
|
4
|
+
// the payload alone and lands on the message the user is looking at.
|
|
5
|
+
import { ChatPanel, CWD_DRAFT_TAIL, CWD_PLACEHOLDER, holdQuestion, PANEL_PREFIX, readDraft, serializeDraft, } from "./panel.js";
|
|
6
|
+
import { context, escapeMrkdwn as esc, section, truncate } from "./slack-render.js";
|
|
7
|
+
import { chatOf } from "./types.js";
|
|
6
8
|
const CWD_VIEW = "cfg_cwd";
|
|
7
9
|
const CWD_BLOCK = "cwd_block";
|
|
8
10
|
const CWD_INPUT = "cwd_input";
|
|
9
|
-
const button = (b) => ({
|
|
11
|
+
const button = (b, value) => ({
|
|
10
12
|
type: "button",
|
|
11
13
|
action_id: `${PANEL_PREFIX}${b.action}`,
|
|
12
|
-
text: { type: "plain_text", text: b.label, emoji: true },
|
|
14
|
+
text: { type: "plain_text", text: truncate(b.label), emoji: true },
|
|
15
|
+
...(value ? { value } : {}),
|
|
13
16
|
});
|
|
14
|
-
const row = (buttons) => ({
|
|
17
|
+
const row = (buttons, value) => ({
|
|
15
18
|
type: "actions",
|
|
16
|
-
elements: buttons.map(button),
|
|
19
|
+
elements: buttons.map((b) => button(b, value)),
|
|
17
20
|
});
|
|
21
|
+
const fresh = (channel, ts, draft) => ({ chatId: channel, ts, draft, dirs: [], sessions: [] });
|
|
18
22
|
export class SlackPanel extends ChatPanel {
|
|
19
23
|
deps;
|
|
20
|
-
platform = "slack";
|
|
21
24
|
fence = ["`", "`"];
|
|
22
25
|
constructor(deps) {
|
|
23
26
|
super(deps);
|
|
@@ -26,30 +29,64 @@ export class SlackPanel extends ChatPanel {
|
|
|
26
29
|
esc(text) {
|
|
27
30
|
return esc(text);
|
|
28
31
|
}
|
|
32
|
+
/** Said on the next redraw when a value could not be read: the picks it held are gone. */
|
|
33
|
+
resetNote;
|
|
34
|
+
/** A malformed value is a stale or foreign card: logged, drawn as an empty draft, and said. */
|
|
35
|
+
parseDraft(raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return {};
|
|
38
|
+
try {
|
|
39
|
+
return readDraft(JSON.parse(raw));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
this.deps.log(`unreadable panel value, draft reset: ${String(err)}`);
|
|
43
|
+
this.resetNote = "Your earlier picks could not be read — pick again.";
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
29
47
|
// --- rendering -------------------------------------------------------------
|
|
30
|
-
blocks(view, note) {
|
|
48
|
+
blocks(view, draft, note) {
|
|
49
|
+
const value = serializeDraft(draft);
|
|
31
50
|
return [
|
|
32
51
|
...view.groups.map((g) => section([`*${g.title}*${g.suffix ?? ""}`, ...g.lines].join("\n"))),
|
|
33
|
-
...(view.picks?.length ? [row(view.picks)] : []),
|
|
34
|
-
...view.rows.filter((r) => r.length).map(row),
|
|
52
|
+
...(view.picks?.length ? [row(view.picks, value)] : []),
|
|
53
|
+
...view.rows.filter((r) => r.length).map((r) => row(r, value)),
|
|
35
54
|
...(note ? [context(esc(note))] : []),
|
|
36
55
|
];
|
|
37
56
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
/** A card that cannot be posted must not look like nothing happening: the
|
|
58
|
+
* thread gets the reason as plain text. */
|
|
59
|
+
async open(key, channel, threadTs, question) {
|
|
60
|
+
const draft = holdQuestion(question);
|
|
61
|
+
const state = fresh(channel, "", draft);
|
|
62
|
+
let sent;
|
|
63
|
+
try {
|
|
64
|
+
sent = await this.deps.api.postMessage({
|
|
65
|
+
channel,
|
|
66
|
+
thread_ts: threadTs,
|
|
67
|
+
text: "Settings",
|
|
68
|
+
blocks: this.blocks(await this.view(key, state), draft),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
this.deps.log(`panel open failed: ${String(err)}`);
|
|
73
|
+
await this.deps.api.postMessage({
|
|
74
|
+
channel,
|
|
75
|
+
thread_ts: threadTs,
|
|
76
|
+
text: `Could not open the panel: ${String(err)}`,
|
|
77
|
+
}).catch((e) => this.deps.log(`panel open failure not posted: ${String(e)}`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.remember(key, { ...state, ts: sent.ts });
|
|
46
81
|
}
|
|
47
82
|
async draw(state, view, note) {
|
|
83
|
+
const said = note ?? this.resetNote;
|
|
84
|
+
this.resetNote = undefined;
|
|
48
85
|
await this.deps.api.updateMessage({
|
|
49
86
|
channel: state.chatId,
|
|
50
87
|
ts: state.ts,
|
|
51
88
|
text: "Settings",
|
|
52
|
-
blocks: this.blocks(view,
|
|
89
|
+
blocks: this.blocks(view, state.draft, said),
|
|
53
90
|
}).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
|
|
54
91
|
}
|
|
55
92
|
async erase(state) {
|
|
@@ -57,35 +94,32 @@ export class SlackPanel extends ChatPanel {
|
|
|
57
94
|
.catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
|
|
58
95
|
}
|
|
59
96
|
// --- actions ---------------------------------------------------------------
|
|
60
|
-
/** Returns false when the action is not ours.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const message = interaction.message;
|
|
65
|
-
if (channel && message)
|
|
66
|
-
await this.open(key, channel, message.thread_ts ?? message.ts);
|
|
67
|
-
});
|
|
97
|
+
/** Returns false when the action is not ours. `run` delivers Start's
|
|
98
|
+
* question as the clicker's message. */
|
|
99
|
+
async onAction(interaction, key, actionId, run) {
|
|
100
|
+
return this.dispatch(key, actionId, interaction, () => fresh(interaction.channel?.id ?? chatOf(key.conversationId), interaction.message?.ts ?? "", this.parseDraft(interaction.actions?.[0]?.value)), run);
|
|
68
101
|
}
|
|
69
102
|
// --- working directory (one typed answer, in a modal) ----------------------
|
|
70
|
-
async promptCwd(key,
|
|
103
|
+
async promptCwd(key, state, interaction) {
|
|
71
104
|
const trigger = interaction.trigger_id;
|
|
72
105
|
if (!trigger) {
|
|
73
106
|
await this.refresh(key, "Could not open the dialog — try again.");
|
|
74
107
|
return;
|
|
75
108
|
}
|
|
109
|
+
const metadata = { conversation: key.conversationId, ts: state.ts, draft: serializeDraft(state.draft) };
|
|
76
110
|
await this.deps.api.openView(trigger, {
|
|
77
111
|
type: "modal",
|
|
78
112
|
callback_id: CWD_VIEW,
|
|
79
|
-
private_metadata:
|
|
80
|
-
title: { type: "plain_text", text: "
|
|
81
|
-
submit: { type: "plain_text", text: "
|
|
113
|
+
private_metadata: JSON.stringify(metadata),
|
|
114
|
+
title: { type: "plain_text", text: "Directory" },
|
|
115
|
+
submit: { type: "plain_text", text: "Set" },
|
|
82
116
|
close: { type: "plain_text", text: "Cancel" },
|
|
83
117
|
blocks: [
|
|
84
118
|
{
|
|
85
119
|
type: "input",
|
|
86
120
|
block_id: CWD_BLOCK,
|
|
87
121
|
label: { type: "plain_text", text: "Working directory" },
|
|
88
|
-
hint: { type: "plain_text", text: `An absolute path. ${
|
|
122
|
+
hint: { type: "plain_text", text: `An absolute path. ${CWD_DRAFT_TAIL}` },
|
|
89
123
|
element: {
|
|
90
124
|
type: "plain_text_input",
|
|
91
125
|
action_id: CWD_INPUT,
|
|
@@ -100,11 +134,18 @@ export class SlackPanel extends ChatPanel {
|
|
|
100
134
|
const view = interaction.view;
|
|
101
135
|
if (view?.callback_id !== CWD_VIEW)
|
|
102
136
|
return false;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
137
|
+
let meta;
|
|
138
|
+
try {
|
|
139
|
+
meta = JSON.parse(view.private_metadata ?? "");
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.deps.log(`cwd modal without readable metadata, dropped: ${String(err)}`);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const key = { channelId: "slack", conversationId: meta.conversation };
|
|
146
|
+
if (!this.state(key))
|
|
147
|
+
this.remember(key, fresh(chatOf(meta.conversation), meta.ts, this.parseDraft(meta.draft)));
|
|
148
|
+
await this.chooseDir(key, (view.state?.values?.[CWD_BLOCK]?.[CWD_INPUT]?.value ?? "").trim());
|
|
108
149
|
return true;
|
|
109
150
|
}
|
|
110
151
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// `actions` row. mrkdwn is not markdown (`*bold*`, `_italic_`, `~strike~`,
|
|
3
3
|
// `<url|label>`), so the agent's markdown is translated; Slack degrades unknown
|
|
4
4
|
// syntax to literal text rather than rejecting the message.
|
|
5
|
+
import { cut } from "../core/reply.js";
|
|
5
6
|
import { balanceFences, chunkText } from "./chunk.js";
|
|
6
7
|
/** Slack caps `markdown` blocks at 12,000 cumulative chars per message. */
|
|
7
8
|
export const MARKDOWN_MAX = 11_000;
|
|
@@ -97,7 +98,8 @@ export const context = (text) => ({
|
|
|
97
98
|
/** `action_id` carries an index: the label is read back off the message Slack
|
|
98
99
|
* echoes with the click, so a button survives a reload. */
|
|
99
100
|
export const OFFER_PREFIX = "sg:";
|
|
100
|
-
|
|
101
|
+
/** Slack refuses a button label over BUTTON_MAX: every button, the panel's too. */
|
|
102
|
+
export const truncate = (label) => cut(label, BUTTON_MAX);
|
|
101
103
|
/** Slack wraps buttons on its own, so there is no row packing to budget. */
|
|
102
104
|
export function actions(labels) {
|
|
103
105
|
if (!labels.length)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A Slack thread as transcript lines for the prompt: what a forwarded thread
|
|
2
|
+
// parent is read into when a person hands the agent a message. One page, since
|
|
3
|
+
// the adapter only inlines a thread it already knows to be short; anything
|
|
4
|
+
// longer is the pier-slack skill's business, in a shell.
|
|
5
|
+
import { ordered, transcript } from "./slack-transcript.js";
|
|
6
|
+
/** Terse: this string goes in a prompt, so every word is paid for per read. */
|
|
7
|
+
const LINE_FORMAT = "<ts> HH:MM name[id]: text, local time, a date line when the day changes;"
|
|
8
|
+
+ " [thread N · <ts>] marks a parent, [file <name> <F…> <size>] an upload";
|
|
9
|
+
/** Error codes an agent can act on become the action; the rest keep their
|
|
10
|
+
* searchable raw code. */
|
|
11
|
+
function explain(err) {
|
|
12
|
+
const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
|
|
13
|
+
return {
|
|
14
|
+
channel_not_found: "no such channel, or Pier's bot cannot see it",
|
|
15
|
+
not_in_channel: "Pier's bot is not in that channel; someone has to run `/invite @Pier` there before it can read",
|
|
16
|
+
missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
|
|
17
|
+
ratelimited: "Slack rate-limited Pier; wait a minute before reading again",
|
|
18
|
+
thread_not_found: "no thread with that ts in this channel",
|
|
19
|
+
}[code] ?? String(err);
|
|
20
|
+
}
|
|
21
|
+
/** Oldest first, one per ts, cut to `limit` at the newest end and saying so. */
|
|
22
|
+
export async function readThread(directory, client, channel, threadTs, limit) {
|
|
23
|
+
let page;
|
|
24
|
+
try {
|
|
25
|
+
page = await client.replies(channel, threadTs, { limit });
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
throw new Error(explain(err));
|
|
29
|
+
}
|
|
30
|
+
const all = ordered(page.messages);
|
|
31
|
+
const window = all.slice(0, limit);
|
|
32
|
+
// Only users: a bot names itself, and users.info on a `B…` id fails.
|
|
33
|
+
const ids = window.map((msg) => msg.user ?? "").filter(Boolean);
|
|
34
|
+
const names = await directory.names(client, ids);
|
|
35
|
+
return {
|
|
36
|
+
count: window.length,
|
|
37
|
+
...(page.nextCursor || all.length > window.length ? { truncated: true } : {}),
|
|
38
|
+
format: LINE_FORMAT,
|
|
39
|
+
messages: transcript(window, names, { ts: true, ids: true, thread: true }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The one transcript format a Slack conversation is read in: the adapter's
|
|
2
|
+
// inline forwarded thread (slack-thread.ts) and `pier slack` (slack-cli.ts)
|
|
3
|
+
// both render through here, so the pier-slack skill describes it once and no
|
|
4
|
+
// header repeats it. Lines, not objects: forty six-key objects spend most of
|
|
5
|
+
// their tokens on key names.
|
|
6
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
7
|
+
const local = (ts) => new Date(Math.floor(Number(ts) * 1000));
|
|
8
|
+
export const localDate = (ts) => {
|
|
9
|
+
const d = local(ts);
|
|
10
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
11
|
+
};
|
|
12
|
+
export const localTime = (ts) => {
|
|
13
|
+
const d = local(ts);
|
|
14
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
15
|
+
};
|
|
16
|
+
export const localStamp = (ts) => `${localDate(ts)} ${localTime(ts)}`;
|
|
17
|
+
/** `+0800`: the header carries it once so HH:MM needs no suffix. */
|
|
18
|
+
export function tzLabel(now = new Date()) {
|
|
19
|
+
const minutes = -now.getTimezoneOffset();
|
|
20
|
+
const abs = Math.abs(minutes);
|
|
21
|
+
return `${minutes < 0 ? "-" : "+"}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
|
|
22
|
+
}
|
|
23
|
+
/** Enough to judge a fetch against the 32 MB cap without arithmetic. */
|
|
24
|
+
const sizeLabel = (bytes) => bytes < 1024
|
|
25
|
+
? `${bytes}B`
|
|
26
|
+
: bytes < 1024 * 1024
|
|
27
|
+
? `${Math.round(bytes / 1024)}KB`
|
|
28
|
+
: `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
29
|
+
const speaker = (msg) => msg.user ?? msg.bot_id;
|
|
30
|
+
/** Oldest first, one per ts (page seams repeat), strictly newer than `after`.
|
|
31
|
+
* Compared as numbers: a `ts` is `<epoch seconds>.<microseconds>`. */
|
|
32
|
+
export function ordered(messages, after) {
|
|
33
|
+
const byTs = new Map();
|
|
34
|
+
for (const msg of messages)
|
|
35
|
+
if (msg.ts)
|
|
36
|
+
byTs.set(msg.ts, msg);
|
|
37
|
+
const all = [...byTs.values()].sort((a, b) => Number(a.ts) - Number(b.ts));
|
|
38
|
+
return after ? all.filter((m) => Number(m.ts) > Number(after)) : all;
|
|
39
|
+
}
|
|
40
|
+
/** The text, or a stand-in for a message whose content is only blocks or attachments. */
|
|
41
|
+
function body(msg) {
|
|
42
|
+
if (msg.text)
|
|
43
|
+
return msg.text;
|
|
44
|
+
const titles = (msg.attachments ?? []).map((a) => a.title || a.fallback || a.text);
|
|
45
|
+
if (titles.length)
|
|
46
|
+
return titles.map((t) => (t ? `[attachment: ${t}]` : "[attachment]")).join(" ");
|
|
47
|
+
return msg.blocks?.length ? "[blocks]" : "";
|
|
48
|
+
}
|
|
49
|
+
function markers(msg, inThread) {
|
|
50
|
+
const out = [];
|
|
51
|
+
if (msg.edited)
|
|
52
|
+
out.push("[edited]");
|
|
53
|
+
if (msg.reply_count && (msg.thread_ts ?? msg.ts) === msg.ts) {
|
|
54
|
+
out.push(`[thread ${msg.reply_count} · ${msg.ts}]`);
|
|
55
|
+
}
|
|
56
|
+
else if (!inThread && msg.thread_ts && msg.thread_ts !== msg.ts) {
|
|
57
|
+
// A reply also sent to the channel; under its parent the indent says where it is.
|
|
58
|
+
out.push(`[in thread ${msg.thread_ts}]`);
|
|
59
|
+
}
|
|
60
|
+
for (const file of msg.files ?? []) {
|
|
61
|
+
out.push(`[file ${file.name ?? file.mimetype ?? "file"} ${file.id}${file.size === undefined ? "" : ` ${sizeLabel(file.size)}`}]`);
|
|
62
|
+
}
|
|
63
|
+
if (msg.reactions?.length) {
|
|
64
|
+
out.push(`[${msg.reactions.map((r) => `:${r.name}: ${r.count ?? 1}`).join(", ")}]`);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** One message as lines: `HH:MM name: text [markers]`, continuation lines
|
|
69
|
+
* indented four past the message. `names` resolves user ids; a bot names itself. */
|
|
70
|
+
export function renderMessage(msg, names, opts = {}, indent = "") {
|
|
71
|
+
const id = speaker(msg);
|
|
72
|
+
const name = msg.user
|
|
73
|
+
? names.get(msg.user) ?? msg.user
|
|
74
|
+
: msg.bot_profile?.name ?? msg.username ?? id;
|
|
75
|
+
const who = !id || !name ? "unknown" : opts.ids && name !== id ? `${name}[${id}]` : name;
|
|
76
|
+
const [first = "", ...rest] = body(msg).split("\n");
|
|
77
|
+
const lines = [
|
|
78
|
+
`${indent}${opts.ts ? `${msg.ts ?? ""} ` : ""}${localTime(msg.ts ?? "0")} ${who}:${first ? ` ${first}` : ""}`,
|
|
79
|
+
...rest.map((line) => `${indent} ${line}`),
|
|
80
|
+
];
|
|
81
|
+
const tail = markers(msg, opts.thread || indent !== "").join(" ");
|
|
82
|
+
if (tail)
|
|
83
|
+
lines[lines.length - 1] += ` ${tail}`;
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
/** Messages in the order given, a date line before the first and whenever the
|
|
87
|
+
* local day changes, replies two spaces under their parent. */
|
|
88
|
+
export function transcript(messages, names, opts = {}) {
|
|
89
|
+
const out = [];
|
|
90
|
+
let day = "";
|
|
91
|
+
const push = (msg, indent) => {
|
|
92
|
+
const date = localDate(msg.ts ?? "0");
|
|
93
|
+
if (date !== day) {
|
|
94
|
+
out.push(date);
|
|
95
|
+
day = date;
|
|
96
|
+
}
|
|
97
|
+
out.push(...renderMessage(msg, names, opts, indent));
|
|
98
|
+
};
|
|
99
|
+
for (const msg of messages) {
|
|
100
|
+
push(msg, "");
|
|
101
|
+
for (const reply of msg.replies ?? [])
|
|
102
|
+
push(reply, " ");
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** `# <scope> · <tz> · <N> messages · last <ts>`: `last` is what `--after` takes next time. */
|
|
107
|
+
export const header = (scope, count, last) => `# ${scope} · ${tzLabel()} · ${count} messages${last ? ` · last ${last}` : ""}`;
|
package/dist/channels/slack.js
CHANGED
|
@@ -10,21 +10,22 @@ import { awaitsTurn } from "../core/reply.js";
|
|
|
10
10
|
import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
|
|
11
11
|
import { logger } from "../log.js";
|
|
12
12
|
import { Chains } from "./chains.js";
|
|
13
|
-
import { parseCommand } from "./commands.js";
|
|
13
|
+
import { parseCommand, settingsDraft } from "./commands.js";
|
|
14
14
|
import { Dedup } from "./dedup.js";
|
|
15
15
|
import { Gatekeeper } from "./gatekeeper.js";
|
|
16
16
|
import { ReceiptLedger, Receipts } from "./receipts.js";
|
|
17
17
|
import { SlackDirectory } from "./slack-directory.js";
|
|
18
18
|
import { SlackApi, } from "./slack-api.js";
|
|
19
19
|
import { SlackOutbound } from "./slack-outbound.js";
|
|
20
|
-
import { readThread, slackToolAvailable } from "./slack-tool.js";
|
|
21
20
|
import { SlackPanel } from "./slack-panel.js";
|
|
21
|
+
import { readThread } from "./slack-thread.js";
|
|
22
22
|
import { context, escapeMrkdwn, offeredLabel } from "./slack-render.js";
|
|
23
23
|
const WORKING = "eyes";
|
|
24
24
|
// The envelope is already acked, so this bounds concurrency (sockets,
|
|
25
25
|
// downloads), not the backlog.
|
|
26
26
|
const MAX_ACTIVE_CHATS = 16;
|
|
27
|
-
|
|
27
|
+
/** Only an idle conversation ages out, so this need not cover a long turn. */
|
|
28
|
+
const RECEIPT_STALE_MS = 10 * 60_000;
|
|
28
29
|
const DRAIN_TIMEOUT_MS = 5000;
|
|
29
30
|
/** Only has to cover a redelivery that crossed our immediate ack. */
|
|
30
31
|
const DEDUP_TTL_MS = 5 * 60_000;
|
|
@@ -90,7 +91,7 @@ export class SlackChannel {
|
|
|
90
91
|
const config = deps.store.get("slack");
|
|
91
92
|
this.log = deps.log ?? ((m) => logger("slack").warn(m));
|
|
92
93
|
this.chains = new Chains(this.log, MAX_ACTIVE_CHATS);
|
|
93
|
-
this.directory =
|
|
94
|
+
this.directory = new SlackDirectory(this.log);
|
|
94
95
|
this.gate = new Gatekeeper(deps.store, "slack", this.log, "channel");
|
|
95
96
|
this.seen = new Dedup(this.log, DEDUP_TTL_MS, DEDUP_MAX);
|
|
96
97
|
this.api = deps.client ?? new SlackApi(config.token, config.appToken, this.log);
|
|
@@ -101,12 +102,12 @@ export class SlackChannel {
|
|
|
101
102
|
setReaction: (channel, ts, emoji) => emoji
|
|
102
103
|
? this.api.addReaction(channel, ts, emoji)
|
|
103
104
|
: this.api.removeReaction(channel, ts, WORKING),
|
|
104
|
-
}, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS);
|
|
105
|
-
if (deps.control) {
|
|
105
|
+
}, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS, (conversationId) => deps.control?.working({ channelId: this.id, conversationId }) ?? false);
|
|
106
|
+
if (deps.control && deps.handoff) {
|
|
106
107
|
this.panel = new SlackPanel({
|
|
107
108
|
api: this.api,
|
|
108
109
|
control: deps.control,
|
|
109
|
-
|
|
110
|
+
handoff: deps.handoff,
|
|
110
111
|
log: this.log,
|
|
111
112
|
});
|
|
112
113
|
}
|
|
@@ -218,14 +219,17 @@ export class SlackChannel {
|
|
|
218
219
|
return this.bind(channel, event.user, threadTs, command?.args ?? "");
|
|
219
220
|
if (command?.name === "stop")
|
|
220
221
|
return this.abortTurn(here, channel, threadTs);
|
|
221
|
-
// A bare `@bot` and `settings` are the same request.
|
|
222
|
-
if (this.panel && (command?.name === "settings" || (!text && !files.length && !shares.length))) {
|
|
223
|
-
return this.panel.open(here, channel, threadTs);
|
|
224
|
-
}
|
|
225
222
|
// Downloading only past the gate: an unauthorized sender must not make the
|
|
226
223
|
// bot pull bytes on their behalf.
|
|
227
224
|
const markers = await this.saveAttachments(files);
|
|
228
225
|
const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
|
|
226
|
+
// A bare `@bot` and `settings` are the same request; `s <text>` drafts a
|
|
227
|
+
// session, so only where this message would start one: a thread root. The
|
|
228
|
+
// held question carries its markers, so Start sends what the user sent.
|
|
229
|
+
const question = threadTs === ts ? settingsDraft(text) : undefined;
|
|
230
|
+
if (this.panel && (question || command?.name === "settings" || (!text && !files.length && !shares.length))) {
|
|
231
|
+
return this.panel.open(here, channel, threadTs, question && [question, ...shared, ...markers].join("\n"));
|
|
232
|
+
}
|
|
229
233
|
// Resolved before the mark: any await between mark() and dispatch is a
|
|
230
234
|
// window in which a previous turn can settle and take this receipt with it.
|
|
231
235
|
const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
|
|
@@ -272,7 +276,9 @@ export class SlackChannel {
|
|
|
272
276
|
});
|
|
273
277
|
if (!admitted)
|
|
274
278
|
return;
|
|
275
|
-
|
|
279
|
+
// Start's question: the card is the message the click was on, so it carries the 👀.
|
|
280
|
+
const run = (text) => this.deliver(key, channel, message.ts, user, text, onMessage);
|
|
281
|
+
if (await this.panel?.onAction(interaction, key, actionId, run))
|
|
276
282
|
return;
|
|
277
283
|
const text = offeredLabel(message.blocks, actionId);
|
|
278
284
|
if (text === undefined) {
|
|
@@ -285,7 +291,6 @@ export class SlackChannel {
|
|
|
285
291
|
await this.retireOptions(channel, message.ts, message.blocks);
|
|
286
292
|
// A bot cannot post as the user, so the pick is echoed: otherwise the
|
|
287
293
|
// thread shows an answer to a request nobody can see, with nothing to carry the eyes.
|
|
288
|
-
const sender = { id: user, name: await this.directory.user(this.api, user) };
|
|
289
294
|
const echo = await this.api.postMessage({
|
|
290
295
|
channel,
|
|
291
296
|
thread_ts: threadTs,
|
|
@@ -294,9 +299,14 @@ export class SlackChannel {
|
|
|
294
299
|
this.log(`option echo failed: ${String(err)}`);
|
|
295
300
|
return undefined;
|
|
296
301
|
});
|
|
302
|
+
await this.deliver(key, channel, echo?.ts, user, text, onMessage);
|
|
303
|
+
}
|
|
304
|
+
/** A click's text as the clicker's message; `ts` is the message that carries the 👀. */
|
|
305
|
+
async deliver(key, channel, ts, user, text, onMessage) {
|
|
306
|
+
const sender = { id: user, name: await this.directory.user(this.api, user) };
|
|
297
307
|
// No await between mark and dispatch — see onMessage.
|
|
298
|
-
if (
|
|
299
|
-
this.receipts.mark(key.conversationId, channel,
|
|
308
|
+
if (ts)
|
|
309
|
+
this.receipts.mark(key.conversationId, channel, ts);
|
|
300
310
|
onMessage({ key, senderId: user, sender, text, mode: "steer" });
|
|
301
311
|
}
|
|
302
312
|
/** Slack will not accept a message with neither text nor blocks, so a turn
|
|
@@ -354,10 +364,8 @@ export class SlackChannel {
|
|
|
354
364
|
}
|
|
355
365
|
return name ?? channel;
|
|
356
366
|
}
|
|
357
|
-
/** The eager thread read
|
|
358
|
-
*
|
|
359
|
-
* gates. `agentTool` only changes the hint, which must not name a tool this
|
|
360
|
-
* session does not have. */
|
|
367
|
+
/** The eager thread read is not gated: a human handing the agent a message
|
|
368
|
+
* is the same act as an upload. */
|
|
361
369
|
async sharedBlock(share) {
|
|
362
370
|
const source = share.original_message;
|
|
363
371
|
const ts = share.ts ?? source?.ts;
|
|
@@ -386,23 +394,19 @@ export class SlackChannel {
|
|
|
386
394
|
const thread = parent && parent.replies <= INLINE_REPLY_MAX
|
|
387
395
|
? await this.sharedThread(parent.channel, parent.ts, parent.replies)
|
|
388
396
|
: { transcript: false, lines: [] };
|
|
389
|
-
// The coordinates
|
|
390
|
-
// a lie when the Console has switched agent access off.
|
|
391
|
-
const how = slackToolAvailable(this.deps.store) ? "read with the slack tool: " : "";
|
|
397
|
+
// The coordinates in `pier slack`'s own words (skills/pier-slack).
|
|
392
398
|
const hint = parent && !thread.transcript
|
|
393
|
-
? `[thread: ${parent.replies} replies —
|
|
399
|
+
? `[thread: ${parent.replies} replies — channel ${parent.channel}, thread_ts ${parent.ts}]`
|
|
394
400
|
: "";
|
|
395
401
|
// The transcript opens with the shared message itself.
|
|
396
402
|
return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
|
|
397
403
|
.filter(Boolean).join("\n");
|
|
398
404
|
}
|
|
399
|
-
/**
|
|
400
|
-
* that fails or comes back cut says so in the prompt (§5). */
|
|
405
|
+
/** A read that fails or comes back cut says so in the prompt (§5). */
|
|
401
406
|
async sharedThread(channel, ts, replies) {
|
|
402
|
-
const deps = { directory: this.directory, log: this.log };
|
|
403
407
|
try {
|
|
404
408
|
// One over the budget, so an undercounting reply_count still reports as cut.
|
|
405
|
-
const read = await readThread(
|
|
409
|
+
const read = await readThread(this.directory, this.api, channel, ts, INLINE_REPLY_MAX + 2);
|
|
406
410
|
if (!read.messages.length)
|
|
407
411
|
return { transcript: false, lines: [] };
|
|
408
412
|
return {
|
|
@@ -410,9 +414,7 @@ export class SlackChannel {
|
|
|
410
414
|
lines: [
|
|
411
415
|
`[thread: ${replies} replies, oldest first — ${read.format}]`,
|
|
412
416
|
...read.messages,
|
|
413
|
-
...(read.
|
|
414
|
-
? [`[thread partly read: ${read.incomplete ?? `cut at ${read.count} lines`}]`]
|
|
415
|
-
: []),
|
|
417
|
+
...(read.truncated ? [`[thread partly read: cut at ${read.count} lines]`] : []),
|
|
416
418
|
],
|
|
417
419
|
};
|
|
418
420
|
}
|
|
@@ -447,6 +449,11 @@ export class SlackChannel {
|
|
|
447
449
|
// The turn ended either way; a 👀 left up by a failed send looks like work.
|
|
448
450
|
await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply), reply.meta);
|
|
449
451
|
}
|
|
452
|
+
/** A web session's thread: the root is the one message Pier posts into a
|
|
453
|
+
* channel's main flow (channels/handoff.ts). */
|
|
454
|
+
async openThread(chatId, note) {
|
|
455
|
+
return conversationId(chatId, await this.out.open(chatId, note));
|
|
456
|
+
}
|
|
450
457
|
/** The 👀 goes on the note itself: the turn it triggers has no message of
|
|
451
458
|
* the user's to carry them. */
|
|
452
459
|
async notify(conversation, note) {
|
|
@@ -457,6 +464,6 @@ export class SlackChannel {
|
|
|
457
464
|
}
|
|
458
465
|
const ts = await this.out.note(channel, threadTs, note);
|
|
459
466
|
if (ts && awaitsTurn(note.origin))
|
|
460
|
-
this.receipts.mark(conversation, channel, ts);
|
|
467
|
+
this.receipts.mark(conversation, channel, ts, note.at);
|
|
461
468
|
}
|
|
462
469
|
}
|
package/dist/channels/types.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// adapters and the Console view (type-only, so no node builtins here). Defaults
|
|
3
3
|
// are least-privilege, and platform values are seeds copied into a chat on
|
|
4
4
|
// discovery, not a runtime fallback: a switch means what it says.
|
|
5
|
-
const PLATFORMS = ["
|
|
5
|
+
const PLATFORMS = ["slack", "lark"];
|
|
6
6
|
/** Validate at the boundary: an unknown platform is a 404, not a new row. */
|
|
7
7
|
export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
|
|
8
8
|
/** Every adapter spells its ids `<chatId>` or `<chatId>/<thread>`, so the chat
|
|
@@ -12,10 +12,8 @@ export const defaultChannelConfig = () => ({
|
|
|
12
12
|
enabled: false,
|
|
13
13
|
token: "",
|
|
14
14
|
appToken: "",
|
|
15
|
-
agentTool: true,
|
|
16
15
|
requireMention: true,
|
|
17
16
|
requireBind: true,
|
|
18
|
-
topicMode: true,
|
|
19
17
|
cwd: "",
|
|
20
18
|
model: null,
|
|
21
19
|
thinking: null,
|