@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.
Files changed (136) hide show
  1. package/README.md +12 -7
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +13 -5
  4. package/dist/agent/listing.js +10 -2
  5. package/dist/agent/packages.js +4 -20
  6. package/dist/agent/pi.js +18 -86
  7. package/dist/channels/attach.js +2 -2
  8. package/dist/channels/commands.js +10 -8
  9. package/dist/channels/config.js +32 -24
  10. package/dist/channels/control.js +60 -14
  11. package/dist/channels/conversations.js +60 -13
  12. package/dist/channels/handoff.js +90 -0
  13. package/dist/channels/lark-api.js +7 -0
  14. package/dist/channels/lark-outbound.js +19 -0
  15. package/dist/channels/lark-panel.js +41 -26
  16. package/dist/channels/lark-render.js +2 -1
  17. package/dist/channels/lark.js +35 -26
  18. package/dist/channels/lines.js +1 -1
  19. package/dist/channels/panel.js +324 -115
  20. package/dist/channels/receipts.js +25 -12
  21. package/dist/channels/routes.js +21 -7
  22. package/dist/channels/runtime.js +21 -7
  23. package/dist/channels/slack-api.js +19 -10
  24. package/dist/channels/slack-cli.js +503 -0
  25. package/dist/channels/slack-directory.js +2 -2
  26. package/dist/channels/slack-outbound.js +11 -1
  27. package/dist/channels/slack-panel.js +81 -40
  28. package/dist/channels/slack-render.js +3 -1
  29. package/dist/channels/slack-thread.js +41 -0
  30. package/dist/channels/slack-transcript.js +107 -0
  31. package/dist/channels/slack.js +38 -31
  32. package/dist/channels/types.js +1 -3
  33. package/dist/cli.js +166 -7
  34. package/dist/core/identity.js +61 -17
  35. package/dist/core/reply.js +19 -15
  36. package/dist/core/router.js +23 -3
  37. package/dist/db.js +33 -0
  38. package/dist/main.js +61 -41
  39. package/dist/paths.js +3 -0
  40. package/dist/secrets.js +2 -1
  41. package/dist/settings.js +3 -12
  42. package/dist/socket.js +99 -0
  43. package/dist/tasks/agent.js +5 -6
  44. package/dist/tasks/callbacks.js +2 -2
  45. package/dist/tasks/cli.js +225 -0
  46. package/dist/tasks/definitions.js +3 -3
  47. package/dist/tasks/execution.js +1 -3
  48. package/dist/tasks/groups.js +2 -5
  49. package/dist/tasks/messages.js +18 -158
  50. package/dist/tasks/operations.js +335 -0
  51. package/dist/tasks/routes.js +1 -11
  52. package/dist/tasks/runs.js +5 -20
  53. package/dist/tasks/service.js +18 -43
  54. package/dist/tasks/store.js +15 -15
  55. package/dist/tools.js +18 -3
  56. package/dist/vault.js +107 -0
  57. package/dist/web/auth.js +2 -1
  58. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
  59. package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
  60. package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
  61. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
  62. package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
  63. package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
  64. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
  65. package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
  67. package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
  68. package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
  69. package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
  70. package/dist/web/public/assets/index-DLszkDUV.css +2 -0
  71. package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
  72. package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
  73. package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
  74. package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
  75. package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
  76. package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
  77. package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
  78. package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
  79. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
  80. package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
  81. package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
  82. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
  83. package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
  84. package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
  85. package/dist/web/public/index.html +2 -2
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/push.js +4 -10
  89. package/dist/web/vault.js +68 -0
  90. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  91. package/dist/websearch/cli.js +73 -0
  92. package/dist/websearch/run.js +273 -0
  93. package/docs/deploy.md +34 -11
  94. package/package.json +2 -3
  95. package/skills/pier-help/SKILL.md +32 -16
  96. package/skills/pier-slack/SKILL.md +52 -71
  97. package/skills/pier-tasks/SKILL.md +60 -148
  98. package/skills/pier-vault/SKILL.md +36 -0
  99. package/skills/pier-web/SKILL.md +50 -0
  100. package/dist/channels/slack-tool.js +0 -416
  101. package/dist/channels/telegram-api.js +0 -86
  102. package/dist/channels/telegram-panel.js +0 -97
  103. package/dist/channels/telegram-render.js +0 -69
  104. package/dist/channels/telegram.js +0 -421
  105. package/dist/extensions/index.js +0 -10
  106. package/dist/extensions/web/index.js +0 -9
  107. package/dist/extensions/web/tools.js +0 -265
  108. package/dist/tasks/tool.js +0 -416
  109. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  110. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  111. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  112. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  113. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  114. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  115. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  116. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  117. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  118. package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
  119. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  120. package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
  121. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  122. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  123. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  124. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  125. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  126. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  129. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
  130. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  131. /package/dist/{extensions/web → websearch}/content.js +0 -0
  132. /package/dist/{extensions/web → websearch}/http.js +0 -0
  133. /package/dist/{extensions/web → websearch}/json.js +0 -0
  134. /package/dist/{extensions/web → websearch}/language.js +0 -0
  135. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  136. /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). The modal's
2
- // `private_metadata` carries the conversation, so a submission needs no
3
- // adapter-side state and survives a reload.
4
- import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
5
- import { context, escapeMrkdwn as esc, section } from "./slack-render.js";
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
- async open(key, channel, threadTs) {
39
- const sent = await this.deps.api.postMessage({
40
- channel,
41
- thread_ts: threadTs,
42
- text: "Settings",
43
- blocks: this.blocks(await this.view(key, channel)),
44
- });
45
- this.remember(key, { chatId: channel, threadTs, ts: sent.ts, models: [] });
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, note),
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
- async onAction(interaction, key, actionId) {
62
- return this.dispatch(key, actionId, interaction, async () => {
63
- const channel = interaction.channel?.id;
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, _state, interaction) {
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: key.conversationId,
80
- title: { type: "plain_text", text: "New session" },
81
- submit: { type: "plain_text", text: "Start" },
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. ${CWD_TAIL}` },
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
- const key = {
104
- channelId: "slack",
105
- conversationId: view.private_metadata ?? "",
106
- };
107
- await this.startSessionIn(key, (view.state?.values?.[CWD_BLOCK]?.[CWD_INPUT]?.value ?? "").trim());
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
- const truncate = (label) => label.length > BUTTON_MAX ? `${label.slice(0, BUTTON_MAX - 1)}\u2026` : label;
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}` : ""}`;
@@ -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
- const RECEIPT_STALE_MS = 30 * 60_000;
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 = deps.directory ?? new SlackDirectory(this.log);
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
- store: deps.store,
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
- if (await this.panel?.onAction(interaction, key, actionId))
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 (echo?.ts)
299
- this.receipts.mark(key.conversationId, channel, echo.ts);
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 runs whether or not `agentTool` is on: a human
358
- * handing the agent a message is the same act as an upload, which nothing
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 carry the tool's own parameter names; naming the tool is
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 — ${how}channel ${parent.channel}, thread_ts ${parent.ts}]`
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
- /** Through the slack tool's own read, so paging and dedup exist once. A read
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(deps, this.api, channel, ts, undefined, INLINE_REPLY_MAX + 2);
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.incomplete || read.truncated
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
  }
@@ -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 = ["telegram", "slack", "lark"];
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,