@timqi/pier 0.1.14 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -22
- package/dist/agent/config.js +2 -2
- package/dist/agent/events.js +32 -3
- package/dist/agent/packages.js +6 -10
- package/dist/agent/pi.js +111 -68
- package/dist/agent/roles.js +103 -0
- package/dist/agent/types.js +78 -0
- package/dist/boards/boards.js +42 -32
- package/dist/channels/chains.js +0 -4
- package/dist/channels/commands.js +7 -12
- package/dist/channels/config.js +27 -1
- package/dist/channels/control.js +5 -16
- package/dist/channels/conversations.js +0 -5
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +16 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +61 -39
- package/dist/channels/lines.js +33 -1
- package/dist/channels/panel.js +17 -107
- package/dist/channels/routes.js +11 -21
- package/dist/channels/runtime.js +34 -25
- package/dist/channels/slack-outbound.js +6 -15
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack.js +63 -57
- package/dist/channels/types.js +1 -1
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +202 -0
- package/dist/core/identity.js +34 -11
- package/dist/core/inbound-file.js +4 -3
- package/dist/core/reply.js +96 -80
- package/dist/core/router.js +111 -118
- package/dist/core/types.js +36 -77
- package/dist/db.js +33 -2
- package/dist/main.js +46 -37
- package/dist/settings.js +25 -18
- package/dist/tasks/agent.js +11 -5
- package/dist/tasks/callbacks.js +55 -5
- package/dist/tasks/cli.js +22 -11
- package/dist/tasks/definitions.js +30 -17
- package/dist/tasks/execution.js +2 -2
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +70 -0
- package/dist/tasks/operations.js +76 -39
- package/dist/tasks/outbox.js +19 -6
- package/dist/tasks/routes.js +19 -249
- package/dist/tasks/service.js +145 -34
- package/dist/tasks/store.js +119 -56
- package/dist/tasks/types.js +5 -0
- package/dist/tools.js +2 -2
- package/dist/web/auth.js +2 -2
- package/dist/web/config-sync.js +4 -5
- package/dist/web/config.js +9 -2
- package/dist/web/explorer.js +2 -2
- package/dist/web/fs.js +16 -1
- package/dist/web/instance.js +8 -5
- package/dist/web/packages.js +1 -1
- package/dist/web/passkeys.js +3 -3
- package/dist/web/providers.js +3 -3
- package/dist/web/public/assets/code-BZwvPani.js +2 -0
- package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
- package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
- package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
- package/dist/web/public/index.html +40 -109
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +0 -2
- package/dist/web/push.js +1 -1
- package/dist/web/server.js +91 -116
- package/dist/web/session-state.js +5 -40
- package/docs/deploy.md +5 -3
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +14 -4
- package/skills/pier-help/SKILL.md +57 -23
- package/skills/pier-tasks/SKILL.md +58 -18
- package/dist/channels/handoff.js +0 -94
- package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
- package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
- package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
- package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
- package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js +0 -85
- package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
- package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
- package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
- package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
- package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
- package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
package/dist/channels/runtime.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Channel lifecycle: which adapters are running; one call for the Console to
|
|
2
2
|
// apply a config change.
|
|
3
|
+
import { Refused } from "../core/router.js";
|
|
3
4
|
import { logger } from "../log.js";
|
|
4
5
|
import { LarkChannel } from "./lark.js";
|
|
5
6
|
import { SlackChannel } from "./slack.js";
|
|
7
|
+
import { chatOf } from "./types.js";
|
|
6
8
|
const ADAPTERS = [
|
|
7
9
|
{ platform: "slack", build: (deps) => new SlackChannel(deps) },
|
|
8
10
|
// Lark's "token" is the App ID and "appToken" the App Secret.
|
|
@@ -15,19 +17,17 @@ const warn = (m) => log.warn(m);
|
|
|
15
17
|
export class ChannelRuntime {
|
|
16
18
|
store;
|
|
17
19
|
router;
|
|
20
|
+
chain;
|
|
18
21
|
control;
|
|
19
|
-
handoff;
|
|
20
22
|
log;
|
|
21
|
-
|
|
23
|
+
running = new Map();
|
|
22
24
|
reloading = Promise.resolve();
|
|
23
25
|
stopped = false;
|
|
24
|
-
constructor(store, router, control,
|
|
25
|
-
/** The pull half only; the push half needs this runtime, so main.ts closes the loop. */
|
|
26
|
-
handoff, log = warn) {
|
|
26
|
+
constructor(store, router, chain, control, log = warn) {
|
|
27
27
|
this.store = store;
|
|
28
28
|
this.router = router;
|
|
29
|
+
this.chain = chain;
|
|
29
30
|
this.control = control;
|
|
30
|
-
this.handoff = handoff;
|
|
31
31
|
this.log = log;
|
|
32
32
|
}
|
|
33
33
|
/** Serialized: two concurrent Console saves would race into duplicate live
|
|
@@ -48,9 +48,10 @@ export class ChannelRuntime {
|
|
|
48
48
|
}
|
|
49
49
|
async restart(adapter) {
|
|
50
50
|
const { platform, build } = adapter;
|
|
51
|
-
const existing = this.
|
|
51
|
+
const existing = this.running.get(platform);
|
|
52
52
|
if (existing) {
|
|
53
|
-
this.
|
|
53
|
+
this.running.delete(platform);
|
|
54
|
+
this.router.unregisterChannel(platform);
|
|
54
55
|
// Never fatal: the config still has to be applied.
|
|
55
56
|
await existing.stop().catch((err) => this.log(`${platform} did not stop cleanly: ${String(err)}`));
|
|
56
57
|
}
|
|
@@ -66,10 +67,11 @@ export class ChannelRuntime {
|
|
|
66
67
|
store: this.store,
|
|
67
68
|
log: (m) => this.log(`${platform}: ${m}`),
|
|
68
69
|
control: this.control,
|
|
69
|
-
handoff: this.handoff,
|
|
70
70
|
});
|
|
71
71
|
try {
|
|
72
72
|
await channel.start((msg) => {
|
|
73
|
+
if (this.control.isHome(msg.key))
|
|
74
|
+
return this.toHead(channel, msg);
|
|
73
75
|
void this.router.dispatch(msg).catch((err) => this.log(`dispatch failed: ${String(err)}`));
|
|
74
76
|
});
|
|
75
77
|
}
|
|
@@ -77,38 +79,45 @@ export class ChannelRuntime {
|
|
|
77
79
|
this.log(`${platform} failed to start: ${String(err)}`);
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
82
|
+
// Running before registered: the router asks main.ts's chatKeyOf, which asks `live`.
|
|
83
|
+
this.running.set(platform, channel);
|
|
80
84
|
this.router.registerChannel(channel);
|
|
81
|
-
this.live.set(platform, channel);
|
|
82
85
|
log.info(`${platform} started`);
|
|
83
86
|
}
|
|
87
|
+
/** The home chat's every message, threaded or not, is the head's under the
|
|
88
|
+
* chat's key. A refusal is the chat's to see (§5): the adapter only logs. */
|
|
89
|
+
toHead(channel, msg) {
|
|
90
|
+
const conversationId = chatOf(msg.key.conversationId);
|
|
91
|
+
this.chain.send(msg, { channelId: msg.key.channelId, conversationId }).catch((err) => {
|
|
92
|
+
this.log(`the conversation did not take a message: ${String(err)}`);
|
|
93
|
+
if (err instanceof Refused)
|
|
94
|
+
return;
|
|
95
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
96
|
+
void channel.notify(conversationId, { text, origin: { kind: "error" } })
|
|
97
|
+
.catch((e) => this.log(`could not report it to ${channel.id}: ${String(e)}`));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
live(platform) {
|
|
101
|
+
return this.running.has(platform);
|
|
102
|
+
}
|
|
84
103
|
/** For restart-note delivery (src/drain.ts), which has no session to report
|
|
85
104
|
* through; false means the platform is not running. */
|
|
86
105
|
async notify(platform, conversationId, text) {
|
|
87
|
-
const channel = this.
|
|
106
|
+
const channel = this.running.get(platform);
|
|
88
107
|
if (!channel)
|
|
89
108
|
return false;
|
|
90
109
|
await channel.notify(conversationId, { text, origin: { kind: "error" } });
|
|
91
110
|
return true;
|
|
92
111
|
}
|
|
93
|
-
running() {
|
|
94
|
-
return [...this.live.keys()];
|
|
95
|
-
}
|
|
96
|
-
/** Throws by name when the platform is not running: the caller's answer is
|
|
97
|
-
* "enable it in Settings", not a silent no-op. */
|
|
98
|
-
async openThread(platform, chatId, note) {
|
|
99
|
-
const channel = this.live.get(platform);
|
|
100
|
-
if (!channel)
|
|
101
|
-
throw new Error(`${platform} is not running`);
|
|
102
|
-
return channel.openThread(chatId, note);
|
|
103
|
-
}
|
|
104
112
|
async stop() {
|
|
105
|
-
// An in-flight restart could otherwise register an adapter after `
|
|
113
|
+
// An in-flight restart could otherwise register an adapter after `running`
|
|
106
114
|
// was cleared — running, unstoppable.
|
|
107
115
|
this.stopped = true;
|
|
108
116
|
await this.reloading.catch(() => { });
|
|
109
|
-
for (const channel of this.
|
|
117
|
+
for (const channel of this.running.values()) {
|
|
118
|
+
this.router.unregisterChannel(channel.id);
|
|
110
119
|
await channel.stop().catch((err) => this.log(`${channel.id} did not stop cleanly: ${String(err)}`));
|
|
111
120
|
}
|
|
112
|
-
this.
|
|
121
|
+
this.running.clear();
|
|
113
122
|
}
|
|
114
123
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// How a turn becomes messages in a Slack thread
|
|
2
|
-
// against its limit, and what an empty turn still has to say.
|
|
3
|
-
import { formatTurnMeta, isSilentReply,
|
|
1
|
+
// How a turn becomes messages in a Slack thread, or the home DM's main flow:
|
|
2
|
+
// which renderer, how to chunk against its limit, and what an empty turn still has to say.
|
|
3
|
+
import { formatTurnMeta, isSilentReply, quietLabel } from "../core/reply.js";
|
|
4
4
|
import { sendAttachments, splitAttachments } from "./attach.js";
|
|
5
|
+
import { noteBody } from "./lines.js";
|
|
5
6
|
import { isBlockRejection } from "./slack-api.js";
|
|
6
7
|
import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
|
|
7
8
|
const footerText = (meta) => escapeMrkdwn(formatTurnMeta(meta));
|
|
@@ -14,6 +15,7 @@ export class SlackOutbound {
|
|
|
14
15
|
this.api = api;
|
|
15
16
|
this.log = log;
|
|
16
17
|
}
|
|
18
|
+
/** No `threadTs` is the home DM's main flow (slack.ts target). */
|
|
17
19
|
async reply(channel, threadTs, reply) {
|
|
18
20
|
// A local file link is dead in Slack: the bytes are uploaded instead.
|
|
19
21
|
const { text: spoken, paths } = splitAttachments(reply.text);
|
|
@@ -43,23 +45,12 @@ export class SlackOutbound {
|
|
|
43
45
|
/** No footer: the turn this input triggers has not ended. Answers with the
|
|
44
46
|
* `ts` of the last message posted, where the caller puts the 👀. */
|
|
45
47
|
async note(channel, threadTs, note) {
|
|
46
|
-
const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
|
|
47
48
|
let ts;
|
|
48
|
-
for (const part of chunk(
|
|
49
|
+
for (const part of chunk(noteBody(note, "_"), this.budget())) {
|
|
49
50
|
ts = await this.post(channel, threadTs, part, []);
|
|
50
51
|
}
|
|
51
52
|
return ts;
|
|
52
53
|
}
|
|
53
|
-
/** The one root Pier posts (no `thread_ts`): the thread a web session is
|
|
54
|
-
* continued in has no user message to hang from. Answers the root's `ts`. */
|
|
55
|
-
async open(channel, note) {
|
|
56
|
-
const link = note.url || "_(no public URL set — Settings → Instance)_";
|
|
57
|
-
const sent = await this.api.postMessage({
|
|
58
|
-
channel,
|
|
59
|
-
text: `Continued from web: *${escapeMrkdwn(note.title)}*\n${link}\n_Reply in this thread to continue._`,
|
|
60
|
-
});
|
|
61
|
-
return sent.ts;
|
|
62
|
-
}
|
|
63
54
|
budget() {
|
|
64
55
|
return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
|
|
65
56
|
}
|
|
@@ -18,7 +18,7 @@ const row = (buttons, value) => ({
|
|
|
18
18
|
type: "actions",
|
|
19
19
|
elements: buttons.map((b) => button(b, value)),
|
|
20
20
|
});
|
|
21
|
-
const fresh = (channel, ts, draft) => ({ chatId: channel, ts, draft, dirs: []
|
|
21
|
+
const fresh = (channel, ts, draft) => ({ chatId: channel, ts, draft, dirs: [] });
|
|
22
22
|
export class SlackPanel extends ChatPanel {
|
|
23
23
|
deps;
|
|
24
24
|
fence = ["`", "`"];
|
|
@@ -97,7 +97,7 @@ export const context = (text) => ({
|
|
|
97
97
|
// --- next-step buttons -------------------------------------------------------
|
|
98
98
|
/** `action_id` carries an index: the label is read back off the message Slack
|
|
99
99
|
* echoes with the click, so a button survives a reload. */
|
|
100
|
-
|
|
100
|
+
const OFFER_PREFIX = "sg:";
|
|
101
101
|
/** Slack refuses a button label over BUTTON_MAX: every button, the panel's too. */
|
|
102
102
|
export const truncate = (label) => cut(label, BUTTON_MAX);
|
|
103
103
|
/** Slack wraps buttons on its own, so there is no row packing to budget. */
|
package/dist/channels/slack.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Slack adapter: normalize inbound Socket Mode envelopes, render outbound turns.
|
|
2
|
-
// Pier never posts into a channel's main flow
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// Pier never posts into a channel's main flow but the home DM's
|
|
3
|
+
// (docs/design/11-im-conversation.md), which is one conversation keyed `<channel>`:
|
|
4
|
+
// any other is `<channel>/<threadTs>` and the thread is the session. Slack-specific: the
|
|
5
|
+
// client intercepts unregistered slash commands, so `%stop` is the spelling
|
|
6
|
+
// that arrives; reactions are short names (`reactions.add` rejects 👀 with
|
|
6
7
|
// `invalid_name`); unacked envelopes are redelivered, so `event_id` is deduplicated.
|
|
8
|
+
import { isChatCommand } from "../core/types.js";
|
|
7
9
|
import { saveInboundAll } from "../core/inbox.js";
|
|
8
10
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
11
|
+
import { skillsText } from "../core/chain.js";
|
|
9
12
|
import { awaitsTurn } from "../core/reply.js";
|
|
10
13
|
import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
|
|
11
14
|
import { logger } from "../log.js";
|
|
@@ -30,12 +33,9 @@ const DRAIN_TIMEOUT_MS = 5000;
|
|
|
30
33
|
/** Only has to cover a redelivery that crossed our immediate ack. */
|
|
31
34
|
const DEDUP_TTL_MS = 5 * 60_000;
|
|
32
35
|
const DEDUP_MAX = 2000;
|
|
33
|
-
/** Bare words with exact arity, since there is no leading `/` to key on: "stop
|
|
34
|
-
* the deploy and tell me why" is a sentence for the agent, not an abort. */
|
|
35
|
-
const BARE_COMMANDS = new Map([["stop", 0], ["settings", 0], ["bind", 1]]);
|
|
36
36
|
/** The only definition of the conversation id format; control.ts decodes with it. */
|
|
37
37
|
const conversationId = (channel, threadTs) => `${channel}/${threadTs}`;
|
|
38
|
-
|
|
38
|
+
const parseConversation = (id) => {
|
|
39
39
|
const at = id.indexOf("/");
|
|
40
40
|
return at < 0
|
|
41
41
|
? { channel: id, threadTs: "" }
|
|
@@ -54,18 +54,6 @@ const sharedFiles = (share) => share.files ?? share.original_message?.files ?? [
|
|
|
54
54
|
/** A token budget, not a Slack limit: past this the agent gets the
|
|
55
55
|
* coordinates and decides for itself. */
|
|
56
56
|
const INLINE_REPLY_MAX = 30;
|
|
57
|
-
/** `/stop` is accepted for muscle memory; a bare `stop` is what actually arrives. */
|
|
58
|
-
function slackCommand(text) {
|
|
59
|
-
const slash = parseCommand(text);
|
|
60
|
-
if (slash)
|
|
61
|
-
return { name: slash.name, args: slash.args };
|
|
62
|
-
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
63
|
-
const name = words[0]?.toLowerCase() ?? "";
|
|
64
|
-
const arity = BARE_COMMANDS.get(name);
|
|
65
|
-
if (arity === undefined || words.length - 1 !== arity)
|
|
66
|
-
return undefined;
|
|
67
|
-
return { name, args: words.slice(1).join(" ") };
|
|
68
|
-
}
|
|
69
57
|
export class SlackChannel {
|
|
70
58
|
deps;
|
|
71
59
|
id = "slack";
|
|
@@ -103,13 +91,8 @@ export class SlackChannel {
|
|
|
103
91
|
? this.api.addReaction(channel, ts, emoji)
|
|
104
92
|
: this.api.removeReaction(channel, ts, WORKING),
|
|
105
93
|
}, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS, (conversationId) => deps.control?.working({ channelId: this.id, conversationId }) ?? false);
|
|
106
|
-
if (deps.control
|
|
107
|
-
this.panel = new SlackPanel({
|
|
108
|
-
api: this.api,
|
|
109
|
-
control: deps.control,
|
|
110
|
-
handoff: deps.handoff,
|
|
111
|
-
log: this.log,
|
|
112
|
-
});
|
|
94
|
+
if (deps.control) {
|
|
95
|
+
this.panel = new SlackPanel({ api: this.api, control: deps.control, log: this.log });
|
|
113
96
|
}
|
|
114
97
|
}
|
|
115
98
|
async start(onMessage) {
|
|
@@ -203,13 +186,15 @@ export class SlackChannel {
|
|
|
203
186
|
this.deps.store.discoverChat("slack", { id: channel, name, kind });
|
|
204
187
|
}
|
|
205
188
|
const text = this.stripMention(raw);
|
|
206
|
-
const command =
|
|
189
|
+
const command = parseCommand(text);
|
|
207
190
|
const threadTs = threadOf(event);
|
|
208
|
-
|
|
191
|
+
// Every message in the home DM, in a thread or not, is the head's, under one key.
|
|
192
|
+
const home = this.isHome(channel);
|
|
193
|
+
const here = { channelId: this.id, conversationId: home ? channel : conversationId(channel, threadTs) };
|
|
209
194
|
const bindRequest = command?.name === "bind" && isDm;
|
|
210
195
|
const admitted = this.gate.admit("message", channel, {
|
|
211
196
|
isDm,
|
|
212
|
-
addressed: this.addressed(raw, event, here),
|
|
197
|
+
addressed: this.addressed(raw, event, here, home),
|
|
213
198
|
userId: event.user,
|
|
214
199
|
bindRequest,
|
|
215
200
|
});
|
|
@@ -220,23 +205,30 @@ export class SlackChannel {
|
|
|
220
205
|
}
|
|
221
206
|
if (bindRequest)
|
|
222
207
|
return this.bind(channel, event.user, threadTs, command?.args ?? "");
|
|
223
|
-
|
|
208
|
+
// The head has no panel and takes `/stop` and `/skills` as chat commands (core/chain.ts).
|
|
209
|
+
if (!home && command?.name === "stop")
|
|
224
210
|
return this.abortTurn(here, channel, threadTs);
|
|
211
|
+
if (!home && command?.name === "skills")
|
|
212
|
+
return this.listSkills(here);
|
|
213
|
+
if (!text && !files.length && !shares.length)
|
|
214
|
+
return this.log(`empty message in ${here.conversationId}, dropped`);
|
|
225
215
|
// Downloading only past the gate: an unauthorized sender must not make the
|
|
226
216
|
// bot pull bytes on their behalf.
|
|
227
217
|
const markers = await this.saveAttachments(files);
|
|
228
218
|
const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// held question carries its markers, so Start sends what the user sent.
|
|
219
|
+
// `/s <text>` drafts a session, so only where this message would start one:
|
|
220
|
+
// a thread root. The held question carries its markers, so Start sends what the user sent.
|
|
232
221
|
const question = threadTs === ts ? settingsDraft(text) : undefined;
|
|
233
|
-
if (this.panel && (question || command?.name === "settings"
|
|
222
|
+
if (this.panel && !home && (question || command?.name === "settings")) {
|
|
234
223
|
return this.panel.open(here, channel, threadTs, question && [question, ...shared, ...markers].join("\n"));
|
|
235
224
|
}
|
|
236
225
|
// Resolved before the mark: any await between mark() and dispatch is a
|
|
237
226
|
// window in which a previous turn can settle and take this receipt with it.
|
|
238
227
|
const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
|
|
239
|
-
|
|
228
|
+
// The head answers a chat command with a note, not a turn: nothing would take a 👀 off it.
|
|
229
|
+
const answered = home && command && !command.args && isChatCommand(command.name);
|
|
230
|
+
if (!answered)
|
|
231
|
+
this.receipts.mark(here.conversationId, channel, ts);
|
|
240
232
|
// Steer: a follow-up is the wrong default when the human is watching a 👀.
|
|
241
233
|
onMessage({
|
|
242
234
|
key: here,
|
|
@@ -267,10 +259,13 @@ export class SlackChannel {
|
|
|
267
259
|
this.log("incomplete block_actions payload, dropped");
|
|
268
260
|
return;
|
|
269
261
|
}
|
|
270
|
-
|
|
262
|
+
// Any click in the home DM is the head's, echoed where the button was;
|
|
263
|
+
// anywhere else a top-level message roots a thread.
|
|
264
|
+
const home = this.isHome(channel);
|
|
265
|
+
const threadTs = home ? message.thread_ts : message.thread_ts ?? message.ts;
|
|
271
266
|
const key = {
|
|
272
267
|
channelId: this.id,
|
|
273
|
-
conversationId: conversationId(channel,
|
|
268
|
+
conversationId: home ? channel : conversationId(channel, message.thread_ts ?? message.ts),
|
|
274
269
|
};
|
|
275
270
|
const admitted = this.gate.admit("action", channel, {
|
|
276
271
|
isDm: (await this.directory.channel(this.api, channel)).kind === "dm",
|
|
@@ -281,7 +276,8 @@ export class SlackChannel {
|
|
|
281
276
|
return;
|
|
282
277
|
// Start's question: the card is the message the click was on, so it carries the 👀.
|
|
283
278
|
const run = (text) => this.deliver(key, channel, message.ts, user, text, onMessage);
|
|
284
|
-
|
|
279
|
+
// The head has no panel: a card left in the DM before it became the home is stale.
|
|
280
|
+
if (!home && (await this.panel?.onAction(interaction, key, actionId, run)))
|
|
285
281
|
return;
|
|
286
282
|
const text = offeredLabel(message.blocks, actionId);
|
|
287
283
|
if (text === undefined) {
|
|
@@ -324,6 +320,11 @@ export class SlackChannel {
|
|
|
324
320
|
await this.deps.control?.abort(key);
|
|
325
321
|
await this.api.postMessage({ channel, thread_ts: threadTs, text: STOPPED });
|
|
326
322
|
}
|
|
323
|
+
/** The head's `/skills` is MainChain's; a thread has no chain, so it is answered here. */
|
|
324
|
+
async listSkills(key) {
|
|
325
|
+
const text = skillsText((await this.deps.control?.skills(key)) ?? []);
|
|
326
|
+
await this.notify(key.conversationId, { text, origin: { kind: "chat-command", command: "skills" } });
|
|
327
|
+
}
|
|
327
328
|
// --- bind ------------------------------------------------------------------
|
|
328
329
|
/** Channels stay silent, but a DM that swallows every message looks broken
|
|
329
330
|
* rather than locked. */
|
|
@@ -333,7 +334,7 @@ export class SlackChannel {
|
|
|
333
334
|
await this.api.postMessage({
|
|
334
335
|
channel,
|
|
335
336
|
thread_ts: threadTs,
|
|
336
|
-
text: bindHint("
|
|
337
|
+
text: bindHint("`%bind <code>`"),
|
|
337
338
|
}).catch((err) => this.log(`bind hint failed: ${String(err)}`));
|
|
338
339
|
}
|
|
339
340
|
async bind(channel, userId, threadTs, code) {
|
|
@@ -347,11 +348,21 @@ export class SlackChannel {
|
|
|
347
348
|
}
|
|
348
349
|
// --- addressing ------------------------------------------------------------
|
|
349
350
|
/** Mentioned, or continuing a thread Pier already owns — durable, so it
|
|
350
|
-
* holds after a restart. */
|
|
351
|
-
addressed(raw, event, key) {
|
|
351
|
+
* holds after a restart. The home key has no row to know. */
|
|
352
|
+
addressed(raw, event, key, home) {
|
|
352
353
|
if (this.me && raw.includes(`<@${this.me}>`))
|
|
353
354
|
return true;
|
|
354
|
-
return !!event.thread_ts && !!this.deps.control?.knows(key);
|
|
355
|
+
return !home && !!event.thread_ts && !!this.deps.control?.knows(key);
|
|
356
|
+
}
|
|
357
|
+
isHome(channel) {
|
|
358
|
+
return this.deps.control?.isHome({ channelId: this.id, conversationId: channel }) ?? false;
|
|
359
|
+
}
|
|
360
|
+
/** A thread, or the home DM's main flow (no `threadTs`); undefined for any other channel's. */
|
|
361
|
+
target(conversation) {
|
|
362
|
+
const { channel, threadTs } = parseConversation(conversation);
|
|
363
|
+
if (threadTs)
|
|
364
|
+
return { channel, threadTs };
|
|
365
|
+
return this.isHome(channel) ? { channel } : undefined;
|
|
355
366
|
}
|
|
356
367
|
/** Slack does not strip the mention for us. */
|
|
357
368
|
stripMention(text) {
|
|
@@ -441,32 +452,27 @@ export class SlackChannel {
|
|
|
441
452
|
}
|
|
442
453
|
// --- outbound --------------------------------------------------------------
|
|
443
454
|
async send(conversation, reply) {
|
|
444
|
-
const
|
|
445
|
-
// No thread is a foreign id; posting it would put a
|
|
446
|
-
// main flow. Refused loudly, receipts still cleared.
|
|
447
|
-
if (!
|
|
455
|
+
const to = this.target(conversation);
|
|
456
|
+
// No thread outside the home DM is a foreign id; posting it would put a
|
|
457
|
+
// turn in the channel's main flow. Refused loudly, receipts still cleared.
|
|
458
|
+
if (!to) {
|
|
448
459
|
this.log(`refusing to answer ${conversation}: no thread in the conversation id`);
|
|
449
460
|
await this.receipts.settle(conversation);
|
|
450
461
|
return;
|
|
451
462
|
}
|
|
452
463
|
// The turn ended either way; a 👀 left up by a failed send looks like work.
|
|
453
|
-
await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply), reply.meta);
|
|
454
|
-
}
|
|
455
|
-
/** A web session's thread: the root is the one message Pier posts into a
|
|
456
|
-
* channel's main flow (channels/handoff.ts). */
|
|
457
|
-
async openThread(chatId, note) {
|
|
458
|
-
return conversationId(chatId, await this.out.open(chatId, note));
|
|
464
|
+
await this.receipts.settleAfter(conversation, () => this.out.reply(to.channel, to.threadTs, reply), reply.meta);
|
|
459
465
|
}
|
|
460
466
|
/** The 👀 goes on the note itself: the turn it triggers has no message of
|
|
461
467
|
* the user's to carry them. */
|
|
462
468
|
async notify(conversation, note) {
|
|
463
|
-
const
|
|
464
|
-
if (!
|
|
469
|
+
const to = this.target(conversation);
|
|
470
|
+
if (!to) {
|
|
465
471
|
this.log(`refusing to post a system note to ${conversation}: no thread in the conversation id`);
|
|
466
472
|
return;
|
|
467
473
|
}
|
|
468
|
-
const ts = await this.out.note(channel, threadTs, note);
|
|
474
|
+
const ts = await this.out.note(to.channel, to.threadTs, note);
|
|
469
475
|
if (ts && awaitsTurn(note.origin))
|
|
470
|
-
this.receipts.mark(conversation, channel, ts, note.at);
|
|
476
|
+
this.receipts.mark(conversation, to.channel, ts, note.at);
|
|
471
477
|
}
|
|
472
478
|
}
|
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 = ["slack", "lark"];
|
|
5
|
+
export 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
|
package/dist/config-sync.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
// One serialized configuration subscriber, shared by Settings and the hourly
|
|
2
|
-
// task.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// task. The source is the authority: what it publishes replaces the local copy
|
|
3
|
+
// of every field the document carries, and a source on another schema version
|
|
4
|
+
// pauses the subscription rather than being merged. Fetched over HTTPS only,
|
|
5
|
+
// redirects followed while they stay HTTPS, JSON capped at 1 MiB so a hostile
|
|
6
|
+
// or broken source cannot exhaust memory.
|
|
7
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
8
|
import { transact } from "./db.js";
|
|
7
9
|
import { logger } from "./log.js";
|
|
8
|
-
import {
|
|
10
|
+
import { parseModelMenu } from "./settings.js";
|
|
9
11
|
const log = logger("config-sync");
|
|
10
12
|
export const CONFIG_SYNC_BYTES = 1024 * 1024;
|
|
13
|
+
/** Bumped by any change to the document's shape — a menu row's fields, the
|
|
14
|
+
* snapshot's, the envelope's. A subscriber applies its own version and
|
|
15
|
+
* nothing else: no field-level compatibility, the older instance upgrades. */
|
|
16
|
+
export const CONFIG_SCHEMA_VERSION = 2;
|
|
11
17
|
const MAX_REDIRECTS = 5;
|
|
18
|
+
class SchemaMismatch extends Error {
|
|
19
|
+
constructor(published) {
|
|
20
|
+
super(`Source publishes configuration schema ${typeof published === "number" ? `v${String(published)}` : "of an unknown version"}; `
|
|
21
|
+
+ `this Pier reads v${String(CONFIG_SCHEMA_VERSION)} — upgrade the older instance, then resume the subscription`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
export function configSourceUrl(raw) {
|
|
13
25
|
let url;
|
|
14
26
|
try {
|
|
@@ -22,16 +34,13 @@ export function configSourceUrl(raw) {
|
|
|
22
34
|
}
|
|
23
35
|
return url;
|
|
24
36
|
}
|
|
25
|
-
export async function downloadConfig(raw,
|
|
37
|
+
export async function downloadConfig(raw, signal) {
|
|
26
38
|
let url = configSourceUrl(raw);
|
|
27
39
|
for (let hop = 0;; hop++) {
|
|
28
40
|
signal.throwIfAborted();
|
|
29
41
|
let res;
|
|
30
42
|
try {
|
|
31
|
-
res = await fetch(url, {
|
|
32
|
-
method: "GET", redirect: "manual", signal,
|
|
33
|
-
headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
|
|
34
|
-
});
|
|
43
|
+
res = await fetch(url, { method: "GET", redirect: "manual", signal, headers: { accept: "application/json" } });
|
|
35
44
|
}
|
|
36
45
|
catch {
|
|
37
46
|
throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source");
|
|
@@ -49,18 +58,17 @@ export async function downloadConfig(raw, etag, signal) {
|
|
|
49
58
|
}
|
|
50
59
|
continue;
|
|
51
60
|
}
|
|
52
|
-
if (res.status !== 200
|
|
61
|
+
if (res.status !== 200) {
|
|
53
62
|
await res.body?.cancel().catch(() => { });
|
|
54
63
|
throw new Error(res.status === 404 || res.status === 410
|
|
55
64
|
? "Source link was revoked or does not exist"
|
|
56
65
|
: `Source returned HTTP ${String(res.status)}`);
|
|
57
66
|
}
|
|
58
|
-
|
|
59
|
-
if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
|
|
67
|
+
if (!/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
|
|
60
68
|
await res.body?.cancel().catch(() => { });
|
|
61
69
|
throw new Error("Source did not return JSON");
|
|
62
70
|
}
|
|
63
|
-
return
|
|
71
|
+
return read(res, signal);
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
async function read(res, signal) {
|
|
@@ -94,7 +102,6 @@ export function configJson(value) {
|
|
|
94
102
|
return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
|
|
95
103
|
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b, "en"))) : entry);
|
|
96
104
|
}
|
|
97
|
-
const hash = (value) => createHash("sha256").update(configJson(value)).digest("hex");
|
|
98
105
|
export class ConfigSync {
|
|
99
106
|
deps;
|
|
100
107
|
#state;
|
|
@@ -102,9 +109,12 @@ export class ConfigSync {
|
|
|
102
109
|
constructor(deps) {
|
|
103
110
|
this.deps = deps;
|
|
104
111
|
const row = deps.db.prepare("SELECT value FROM settings WHERE key = ?").get(KEY);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
// Field by field: a row an older Pier wrote carries keys this one no longer keeps.
|
|
113
|
+
const stored = row ? JSON.parse(row.value) : {};
|
|
114
|
+
this.#state = {
|
|
115
|
+
instanceId: stored.instanceId ?? randomUUID(), token: stored.token ?? null, url: stored.url ?? "",
|
|
116
|
+
enabled: stored.enabled ?? false, lastChecked: stored.lastChecked ?? null, lastApplied: stored.lastApplied ?? null,
|
|
117
|
+
error: stored.error ?? null, needsReload: stored.needsReload ?? false,
|
|
108
118
|
};
|
|
109
119
|
if (!row)
|
|
110
120
|
this.#save();
|
|
@@ -154,11 +164,11 @@ export class ConfigSync {
|
|
|
154
164
|
const local = await this.#local();
|
|
155
165
|
if (this.#state.token !== expected)
|
|
156
166
|
return null;
|
|
157
|
-
const document = { schemaVersion:
|
|
167
|
+
const document = { schemaVersion: CONFIG_SCHEMA_VERSION, instanceId: this.#state.instanceId, ...local };
|
|
158
168
|
const body = configJson(document);
|
|
159
169
|
if (Buffer.byteLength(body) > CONFIG_SYNC_BYTES)
|
|
160
170
|
throw new Error("Configuration exceeds 1 MiB");
|
|
161
|
-
return
|
|
171
|
+
return body;
|
|
162
172
|
}
|
|
163
173
|
#parse(raw) {
|
|
164
174
|
if (Buffer.byteLength(raw) > CONFIG_SYNC_BYTES)
|
|
@@ -173,8 +183,8 @@ export class ConfigSync {
|
|
|
173
183
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
174
184
|
throw new Error("Invalid configuration document");
|
|
175
185
|
const object = value;
|
|
176
|
-
if (object.schemaVersion !==
|
|
177
|
-
throw new
|
|
186
|
+
if (object.schemaVersion !== CONFIG_SCHEMA_VERSION)
|
|
187
|
+
throw new SchemaMismatch(object.schemaVersion);
|
|
178
188
|
if (Object.keys(object).some((key) => !["schemaVersion", "instanceId", "agent", "modelMenu"].includes(key))) {
|
|
179
189
|
throw new Error("Unexpected configuration fields");
|
|
180
190
|
}
|
|
@@ -182,9 +192,9 @@ export class ConfigSync {
|
|
|
182
192
|
throw new Error("Invalid source instance ID");
|
|
183
193
|
if (object.instanceId === this.#state.instanceId)
|
|
184
194
|
throw new Error("An instance cannot subscribe to itself");
|
|
185
|
-
const modelMenu =
|
|
186
|
-
if (
|
|
187
|
-
throw new Error(
|
|
195
|
+
const modelMenu = parseModelMenu(object.modelMenu);
|
|
196
|
+
if (typeof modelMenu === "string")
|
|
197
|
+
throw new Error(`Invalid model menu: ${modelMenu}`);
|
|
188
198
|
return { agent: this.deps.normalizeAgent(object.agent), modelMenu };
|
|
189
199
|
}
|
|
190
200
|
subscribe(url) {
|
|
@@ -208,14 +218,10 @@ export class ConfigSync {
|
|
|
208
218
|
async #sync(rawUrl, signal) {
|
|
209
219
|
try {
|
|
210
220
|
const url = configSourceUrl(rawUrl).href;
|
|
211
|
-
const local = await this.#local();
|
|
212
|
-
const etag = url === this.#state.url && hash(local) === this.#state.appliedHash ? this.#state.etag : null;
|
|
213
221
|
const timeout = AbortSignal.timeout(15_000);
|
|
214
|
-
const response = await (this.deps.download ?? downloadConfig)(url,
|
|
222
|
+
const response = await (this.deps.download ?? downloadConfig)(url, signal ? AbortSignal.any([signal, timeout]) : timeout);
|
|
215
223
|
signal?.throwIfAborted();
|
|
216
|
-
|
|
217
|
-
throw new Error("Source returned 304 without a matching local configuration");
|
|
218
|
-
const next = response.status === 304 ? local : this.#parse(response.body);
|
|
224
|
+
const next = this.#parse(response);
|
|
219
225
|
let changed = false;
|
|
220
226
|
const previous = structuredClone(this.#state);
|
|
221
227
|
const commit = (filesChanged) => transact(this.deps.db, () => {
|
|
@@ -225,15 +231,12 @@ export class ConfigSync {
|
|
|
225
231
|
if (menuChanged)
|
|
226
232
|
this.deps.settings.setModelMenu(next.modelMenu);
|
|
227
233
|
this.#save({
|
|
228
|
-
url, enabled: true,
|
|
229
|
-
appliedHash: hash(next), lastChecked: Date.now(),
|
|
234
|
+
url, enabled: true, lastChecked: Date.now(),
|
|
230
235
|
lastApplied: changed ? Date.now() : previous.lastApplied,
|
|
231
236
|
needsReload: changed || previous.needsReload, error: null,
|
|
232
237
|
});
|
|
233
238
|
});
|
|
234
239
|
try {
|
|
235
|
-
// Reconcile under the file lock even on 304: local edits may have
|
|
236
|
-
// completed while the conditional download was in flight.
|
|
237
240
|
await this.deps.config.applySnapshot(next.agent, commit);
|
|
238
241
|
}
|
|
239
242
|
catch (err) {
|
|
@@ -249,13 +252,15 @@ export class ConfigSync {
|
|
|
249
252
|
}
|
|
250
253
|
this.#save({ needsReload: false });
|
|
251
254
|
}
|
|
252
|
-
return changed ? "Configuration applied; reload completed" :
|
|
255
|
+
return changed ? "Configuration applied; reload completed" : "Configuration unchanged";
|
|
253
256
|
}
|
|
254
257
|
catch (err) {
|
|
255
258
|
const error = err instanceof Error ? err.message : "Configuration sync failed";
|
|
256
259
|
// The message is the operator's; the cause, when there is one, is the log's.
|
|
257
260
|
log.error(error, err instanceof Error ? err.cause : err);
|
|
258
|
-
|
|
261
|
+
// A version gap is not retried hourly: the fix is an upgrade, and the
|
|
262
|
+
// notice stands until the operator resumes.
|
|
263
|
+
this.#save({ lastChecked: Date.now(), error, ...(err instanceof SchemaMismatch ? { enabled: false } : {}) });
|
|
259
264
|
throw new Error(error);
|
|
260
265
|
}
|
|
261
266
|
}
|