@timqi/pier 0.0.8 → 0.0.15
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 +26 -9
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +279 -32
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/conversations.js +10 -0
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +99 -11
- package/dist/db.js +87 -0
- package/dist/extensions/index.js +37 -0
- package/dist/extensions/web/anthropic.js +118 -0
- package/dist/extensions/web/artifacts.js +62 -0
- package/dist/extensions/web/content.js +130 -0
- package/dist/extensions/web/http.js +106 -0
- package/dist/extensions/web/index.js +9 -0
- package/dist/extensions/web/json.js +5 -0
- package/dist/extensions/web/language.js +47 -0
- package/dist/extensions/web/openai.js +112 -0
- package/dist/extensions/web/provider.js +121 -0
- package/dist/extensions/web/tools.js +304 -0
- package/dist/limits.js +14 -0
- package/dist/main.js +76 -10
- package/dist/paths.js +21 -1
- package/dist/settings.js +112 -13
- package/dist/tasks/agent.js +18 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/definitions.js +56 -12
- package/dist/tasks/execution.js +5 -1
- package/dist/tasks/groups.js +4 -4
- package/dist/tasks/messages.js +4 -2
- package/dist/tasks/runs.js +2 -2
- package/dist/tasks/service.js +16 -6
- package/dist/tasks/tool.js +0 -12
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +175 -22
- package/dist/web/providers.js +16 -0
- package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
- package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
- package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +33 -9
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +170 -52
- package/dist/web/session-state.js +57 -44
- package/dist/web/terminal.js +34 -4
- package/dist/web/types.js +5 -0
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
- package/dist/web/public/assets/index-gcSJ9QZ5.css +0 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Outbound attachments: which links in a turn are files the platform has to
|
|
2
|
+
// carry, and the bytes behind them.
|
|
3
|
+
//
|
|
4
|
+
// The agent links a file it produced by absolute `file://` URL — the
|
|
5
|
+
// convention core/reply.ts hands it, spelled the same way inbound
|
|
6
|
+
// (core/inbound-file.ts). The web chat renders that link as a card because the
|
|
7
|
+
// browser can fetch the bytes back over an authenticated route; an IM client
|
|
8
|
+
// cannot, and a `file:///…` link in Slack is a dead path on someone else's
|
|
9
|
+
// machine. So an adapter uploads the file to the platform instead, and the
|
|
10
|
+
// link's label stays behind as the words around it.
|
|
11
|
+
//
|
|
12
|
+
// The upload itself is per-platform and stays in each `*-api.ts`; what is
|
|
13
|
+
// shared — the grammar, the caps, and the line a failed attachment still owes
|
|
14
|
+
// the conversation — lives here so three adapters do not each have a copy.
|
|
15
|
+
import { readFile, stat } from "node:fs/promises";
|
|
16
|
+
import { basename, extname } from "node:path";
|
|
17
|
+
import { lostMarker } from "../core/inbound-file.js";
|
|
18
|
+
/**
|
|
19
|
+
* One cap for every platform: Telegram refuses a photo past 10 MB, which is
|
|
20
|
+
* the smallest of the three, and a turn that lands on one chat and not on
|
|
21
|
+
* another is worse than a turn that is honest everywhere.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
|
|
24
|
+
/** Per turn. Linking a directory's worth of files is a mistake, not a plan. */
|
|
25
|
+
const MAX_ATTACHMENTS = 5;
|
|
26
|
+
/** Extensions the platforms show inline. Everything else goes as a document —
|
|
27
|
+
* svg included, deliberately: it is markup, and it renders as a file. */
|
|
28
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
|
29
|
+
/** `[label](file:///abs/path)`, inline or on a line of its own. The optional
|
|
30
|
+
* `!` is an image embed, which is the same request with a different sigil. */
|
|
31
|
+
const LINK = /!?\[([^\]\n]*)\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)/g;
|
|
32
|
+
/** A trailing slash or a bare root would otherwise leave an unnamed file. */
|
|
33
|
+
const nameOf = (path) => basename(path) || "file";
|
|
34
|
+
/**
|
|
35
|
+
* Split a turn's markdown into the text an IM chat should show and the files
|
|
36
|
+
* it linked. Each link collapses to its label — or to the file's name when the
|
|
37
|
+
* agent wrote none — so the sentence it sat in still reads, and the turn never
|
|
38
|
+
* becomes empty just because its only content was an attachment.
|
|
39
|
+
*/
|
|
40
|
+
export function splitAttachments(markdown) {
|
|
41
|
+
const paths = [];
|
|
42
|
+
const text = markdown.replace(LINK, (_m, label, raw) => {
|
|
43
|
+
let path = raw;
|
|
44
|
+
try {
|
|
45
|
+
path = decodeURIComponent(raw);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* not percent-encoded — take the path as written */
|
|
49
|
+
}
|
|
50
|
+
if (!paths.includes(path))
|
|
51
|
+
paths.push(path);
|
|
52
|
+
return label || nameOf(path);
|
|
53
|
+
});
|
|
54
|
+
return { text, paths };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Upload every file a turn linked, and return the line the conversation still
|
|
58
|
+
* owes: an attachment that never arrived must not look like an attachment that
|
|
59
|
+
* was never mentioned (AGENTS.md 5b), so each failure is named in the chat as
|
|
60
|
+
* well as in the log. Empty string when everything landed.
|
|
61
|
+
*/
|
|
62
|
+
export async function sendAttachments(paths, upload, log) {
|
|
63
|
+
const lost = [];
|
|
64
|
+
const fail = (path, reason) => {
|
|
65
|
+
log(`attachment ${path} not sent: ${reason}`);
|
|
66
|
+
lost.push(lostMarker(nameOf(path), reason));
|
|
67
|
+
};
|
|
68
|
+
for (const path of paths.slice(0, MAX_ATTACHMENTS)) {
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(path);
|
|
71
|
+
if (!info.isFile())
|
|
72
|
+
throw new Error("not a file");
|
|
73
|
+
if (info.size > MAX_ATTACH_BYTES)
|
|
74
|
+
throw new Error(`too large (>${MAX_ATTACH_BYTES} bytes)`);
|
|
75
|
+
const name = nameOf(path);
|
|
76
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
77
|
+
await upload({ name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
fail(path, err instanceof Error ? err.message : String(err));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const path of paths.slice(MAX_ATTACHMENTS)) {
|
|
84
|
+
fail(path, `more than ${MAX_ATTACHMENTS} files in one turn`);
|
|
85
|
+
}
|
|
86
|
+
return lost.join("\n");
|
|
87
|
+
}
|
package/dist/channels/control.js
CHANGED
|
@@ -29,11 +29,11 @@ export function createControl({ router, factory, conversations, store }) {
|
|
|
29
29
|
if (!session)
|
|
30
30
|
return null;
|
|
31
31
|
// AgentSession has no cwd; the factory's listing is where it lives.
|
|
32
|
-
const
|
|
32
|
+
const summary = await factory.find(session.id);
|
|
33
33
|
const usage = session.contextUsage;
|
|
34
34
|
return {
|
|
35
35
|
sessionId: session.id,
|
|
36
|
-
cwd:
|
|
36
|
+
cwd: summary?.cwd ?? "",
|
|
37
37
|
state: session.state,
|
|
38
38
|
model: session.model,
|
|
39
39
|
thinking: session.thinkingLevel,
|
|
@@ -29,6 +29,16 @@ export class ConversationStore {
|
|
|
29
29
|
session_id = excluded.session_id, updated_at = excluded.updated_at
|
|
30
30
|
`).run(key.channelId, key.conversationId, sessionId, Date.now());
|
|
31
31
|
}
|
|
32
|
+
/** Which channel owns this session, durably — the router's own answer is
|
|
33
|
+
* in-memory and becomes undefined the moment an idle session is evicted, so
|
|
34
|
+
* a surface asking "was this turn already delivered to a chat?" long after
|
|
35
|
+
* the turn cannot use it. Sessions with no row are nobody's conversation. */
|
|
36
|
+
channelOf(sessionId) {
|
|
37
|
+
const row = this.db.prepare(`
|
|
38
|
+
SELECT channel_id FROM conversations WHERE session_id = ? LIMIT 1
|
|
39
|
+
`).get(sessionId);
|
|
40
|
+
return row?.channel_id;
|
|
41
|
+
}
|
|
32
42
|
/** Drop a mapping whose session Pi no longer has, so the next message
|
|
33
43
|
* starts a fresh one instead of failing forever. */
|
|
34
44
|
forget(key) {
|
|
@@ -136,6 +136,44 @@ export class LarkApi {
|
|
|
136
136
|
});
|
|
137
137
|
return { messageId: ok("message.reply", res).data?.message_id ?? "" };
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Two calls: the bytes go to the platform first and come back as a key,
|
|
141
|
+
* then the key is posted as a message. Images take the image endpoint so
|
|
142
|
+
* they render inline; everything else is a `stream` file, which is Lark's
|
|
143
|
+
* name for "a file whose type I am not claiming to know".
|
|
144
|
+
*
|
|
145
|
+
* The SDK unwraps an upload response to its `data`, so a business failure
|
|
146
|
+
* arrives as a missing key rather than as a code — hence the explicit throw
|
|
147
|
+
* instead of `ok()`.
|
|
148
|
+
*/
|
|
149
|
+
async uploadFile(rootId, file) {
|
|
150
|
+
const bytes = Buffer.from(file.bytes);
|
|
151
|
+
let content;
|
|
152
|
+
if (file.image) {
|
|
153
|
+
const res = await this.client.im.v1.image.create({
|
|
154
|
+
data: { image_type: "message", image: bytes },
|
|
155
|
+
});
|
|
156
|
+
if (!res?.image_key)
|
|
157
|
+
throw new Error(`lark image.create: no image_key for ${file.name}`);
|
|
158
|
+
content = { image_key: res.image_key };
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const res = await this.client.im.v1.file.create({
|
|
162
|
+
data: { file_type: "stream", file_name: file.name, file: bytes },
|
|
163
|
+
});
|
|
164
|
+
if (!res?.file_key)
|
|
165
|
+
throw new Error(`lark file.create: no file_key for ${file.name}`);
|
|
166
|
+
content = { file_key: res.file_key };
|
|
167
|
+
}
|
|
168
|
+
ok("message.reply", await this.client.im.v1.message.reply({
|
|
169
|
+
path: { message_id: rootId },
|
|
170
|
+
data: {
|
|
171
|
+
msg_type: file.image ? "image" : "file",
|
|
172
|
+
content: JSON.stringify(content),
|
|
173
|
+
reply_in_thread: true,
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
139
177
|
async patchCard(messageId, card) {
|
|
140
178
|
ok("message.patch", await this.client.im.v1.message.patch({
|
|
141
179
|
path: { message_id: messageId },
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// adapter keeps the 👀 receipts, because those are about the turn ending, not
|
|
7
7
|
// about what was said.
|
|
8
8
|
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
9
|
+
import { sendAttachments, splitAttachments } from "./attach.js";
|
|
9
10
|
import { button, buttonRow, card, chunk, footer, LARK_MAX, markdown, OFFER_PREFIX, withFooter, withoutButtons, } from "./lark-render.js";
|
|
10
11
|
/** How many sent cards the retire cache remembers (avibe keeps 200). */
|
|
11
12
|
const SENT_CACHE = 200;
|
|
@@ -37,11 +38,14 @@ export class LarkOutbound {
|
|
|
37
38
|
* platform — the card is remembered so retire() can rebuild it without them.
|
|
38
39
|
*/
|
|
39
40
|
async reply(root, reply) {
|
|
40
|
-
|
|
41
|
+
// A file the agent linked lives on Pier's machine, so the link is dead in
|
|
42
|
+
// Lark: the bytes are uploaded instead and the label stays in the text.
|
|
43
|
+
const { text: spoken, paths } = splitAttachments(reply.text);
|
|
44
|
+
const text = spoken.trim();
|
|
41
45
|
const meta = reply.meta ? formatTurnMeta(reply.meta) : "";
|
|
42
46
|
const quiet = isSilentReply(reply) ? quietLabel(reply.silence) : "";
|
|
43
47
|
const note = [quiet, meta].filter(Boolean).join(" · ");
|
|
44
|
-
if (!(text || reply.suggestions.length || note))
|
|
48
|
+
if (!(text || reply.suggestions.length || note || paths.length))
|
|
45
49
|
return;
|
|
46
50
|
const row = reply.suggestions.length
|
|
47
51
|
? buttonRow(reply.suggestions.map((label, index) => button(label, { key: `${OFFER_PREFIX}${index}`, root, label })))
|
|
@@ -62,6 +66,11 @@ export class LarkOutbound {
|
|
|
62
66
|
if (last && row && messageId)
|
|
63
67
|
this.remember(messageId, card(elements));
|
|
64
68
|
}
|
|
69
|
+
// Attachments follow the words, so the card introducing them is above
|
|
70
|
+
// them; anything that could not be sent says so in the thread.
|
|
71
|
+
const lost = await sendAttachments(paths, (file) => this.api.uploadFile(root, file), this.log);
|
|
72
|
+
if (lost)
|
|
73
|
+
await this.api.replyCard(root, card([markdown(lost)]));
|
|
65
74
|
}
|
|
66
75
|
/**
|
|
67
76
|
* Take the buttons off a card one option was just taken from — the rest
|
|
@@ -293,4 +293,40 @@ export class SlackApi {
|
|
|
293
293
|
// Bounded mid-stream: the event's size metadata is the platform's word.
|
|
294
294
|
return { bytes: await readCapped(res.body, maxBytes), mimeType };
|
|
295
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Three calls, because that is what Slack's current upload is: ask for a
|
|
298
|
+
* one-shot URL, POST the bytes to it (that host is not the Web API and
|
|
299
|
+
* answers with plain text, not JSON), then tell Slack where the file goes.
|
|
300
|
+
* `files.upload` did it in one, and is retired.
|
|
301
|
+
*/
|
|
302
|
+
async uploadFile(channel, threadTs, file) {
|
|
303
|
+
// A read method: form-encoded, or Slack ignores the body (see read()).
|
|
304
|
+
const slot = await this.read("files.getUploadURLExternal", { filename: file.name, length: file.bytes.length }).catch((err) => {
|
|
305
|
+
// An app installed before Pier could upload has every other scope, so
|
|
306
|
+
// this reads as a mysterious refusal in the chat. Name the fix instead:
|
|
307
|
+
// the manifest is only applied when an app is *created*.
|
|
308
|
+
if (!/missing_scope/.test(String(err)))
|
|
309
|
+
throw err;
|
|
310
|
+
throw new Error("the Slack app is missing the files:write scope — add it under " +
|
|
311
|
+
"OAuth & Permissions and reinstall the app");
|
|
312
|
+
});
|
|
313
|
+
if (!slot.upload_url || !slot.file_id) {
|
|
314
|
+
throw new Error("slack files.getUploadURLExternal: no upload url");
|
|
315
|
+
}
|
|
316
|
+
const put = await fetch(slot.upload_url, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: { "content-type": "application/octet-stream" },
|
|
319
|
+
// Copied into a fresh view: a request body must be backed by an
|
|
320
|
+
// ArrayBuffer, and a Buffer read off disk is the wider ArrayBufferLike.
|
|
321
|
+
body: new Uint8Array(file.bytes),
|
|
322
|
+
signal: AbortSignal.timeout(120_000),
|
|
323
|
+
});
|
|
324
|
+
if (!put.ok)
|
|
325
|
+
throw new Error(`slack file upload: ${put.status}`);
|
|
326
|
+
await this.call("files.completeUploadExternal", {
|
|
327
|
+
files: [{ id: slot.file_id, title: file.name }],
|
|
328
|
+
channel_id: channel,
|
|
329
|
+
thread_ts: threadTs,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
296
332
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// limit, and what an empty turn still has to say. The adapter keeps the 👀
|
|
6
6
|
// receipts, because those are about the turn ending, not about what was said.
|
|
7
7
|
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
8
|
+
import { sendAttachments, splitAttachments } from "./attach.js";
|
|
8
9
|
import { isBlockRejection } from "./slack-api.js";
|
|
9
10
|
import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
|
|
10
11
|
/**
|
|
@@ -30,7 +31,10 @@ export class SlackOutbound {
|
|
|
30
31
|
* and that is still something to show.
|
|
31
32
|
*/
|
|
32
33
|
async reply(channel, threadTs, reply) {
|
|
33
|
-
|
|
34
|
+
// A file the agent linked lives on Pier's machine, so the link is dead in
|
|
35
|
+
// Slack: the bytes are uploaded instead and the label stays in the text.
|
|
36
|
+
const { text: spoken, paths } = splitAttachments(reply.text);
|
|
37
|
+
const text = spoken.trim();
|
|
34
38
|
const footer = reply.meta ? footerText(reply.meta) : "";
|
|
35
39
|
const row = actions(reply.suggestions);
|
|
36
40
|
// A turn that produced no text still posts its footer, and says which kind
|
|
@@ -41,7 +45,7 @@ export class SlackOutbound {
|
|
|
41
45
|
const quiet = isSilentReply(reply)
|
|
42
46
|
? `_${quietLabel(reply.silence && escapeMrkdwn(reply.silence))}_`
|
|
43
47
|
: "";
|
|
44
|
-
if (!(text || row || footer || quiet))
|
|
48
|
+
if (!(text || row || footer || quiet || paths.length))
|
|
45
49
|
return;
|
|
46
50
|
const parts = text ? chunk(text, this.budget()) : [""];
|
|
47
51
|
for (const [i, part] of parts.entries()) {
|
|
@@ -55,6 +59,12 @@ export class SlackOutbound {
|
|
|
55
59
|
...(last && row ? [row] : []),
|
|
56
60
|
]);
|
|
57
61
|
}
|
|
62
|
+
// Attachments follow the words, so the message introducing them is above
|
|
63
|
+
// them; anything that could not be sent says so in the thread.
|
|
64
|
+
const lost = await sendAttachments(paths, (file) => this.api.uploadFile(channel, threadTs, file), this.log);
|
|
65
|
+
// Unescaped, like every other body: post() escapes on the path that needs it.
|
|
66
|
+
if (lost)
|
|
67
|
+
await this.post(channel, threadTs, lost, []);
|
|
58
68
|
}
|
|
59
69
|
/**
|
|
60
70
|
* A system note: quoted, labelled with where it came from, and deliberately
|
|
@@ -41,11 +41,25 @@ export function toTs(value) {
|
|
|
41
41
|
throw new Error(`not a time: ${value}`);
|
|
42
42
|
return String(parsed / 1000);
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Whether a session opened now is given the tool at all: the same two switches
|
|
46
|
+
* `handleSlackTool` checks, asked before the description is paid for. The call
|
|
47
|
+
* keeps its own checks and its own two messages — a session opened while Slack
|
|
48
|
+
* was configured outlives the operator switching it off, and that turn has to
|
|
49
|
+
* say so rather than find the tool quietly gone.
|
|
50
|
+
*/
|
|
51
|
+
export function slackToolAvailable(store) {
|
|
52
|
+
const config = store.get("slack");
|
|
53
|
+
return config.enabled && !!config.token && config.agentTool;
|
|
54
|
+
}
|
|
55
|
+
export function slackToolSpec(execute, available) {
|
|
45
56
|
return {
|
|
46
57
|
name: "slack",
|
|
47
58
|
label: "Slack",
|
|
48
|
-
|
|
59
|
+
// One screen of contract; the paragraph this once was lives in the
|
|
60
|
+
// pier-slack skill, which the description sends the model to before it
|
|
61
|
+
// posts — the part that goes wrong without instructions.
|
|
62
|
+
description: "Read and write Slack through Pier, which holds the bot token. Operations: context (which Slack conversation this session is in), read_channel (transcript for a time range), read_thread (one thread; only what is new since a message via after), read_message (the one at ts), post, edit/delete (Pier's own messages only), channels (what Pier can reach). Omit channel and thread_ts to act on the conversation you are in. since/until/after accept ISO 8601, epoch seconds or a ts. Every read fetches live; nothing is kept between calls. @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
49
63
|
parameters: Type.Object({
|
|
50
64
|
// A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
|
|
51
65
|
operation: Type.Unsafe({
|
|
@@ -56,6 +70,7 @@ export function slackToolSpec(execute) {
|
|
|
56
70
|
"read_thread",
|
|
57
71
|
"read_message",
|
|
58
72
|
"post",
|
|
73
|
+
"edit",
|
|
59
74
|
"delete",
|
|
60
75
|
"channels",
|
|
61
76
|
],
|
|
@@ -69,12 +84,13 @@ export function slackToolSpec(execute) {
|
|
|
69
84
|
until: Type.Optional(Type.String()),
|
|
70
85
|
/** Strictly newer than this — "what changed since I last looked". */
|
|
71
86
|
after: Type.Optional(Type.String()),
|
|
72
|
-
/** The one message `read_message` or `delete` is about. */
|
|
87
|
+
/** The one message `read_message`, `edit` or `delete` is about. */
|
|
73
88
|
ts: Type.Optional(Type.String()),
|
|
74
89
|
limit: Type.Optional(Type.Number()),
|
|
75
90
|
thread_ts: Type.Optional(Type.String()),
|
|
76
91
|
text: Type.Optional(Type.String()),
|
|
77
92
|
}),
|
|
93
|
+
available,
|
|
78
94
|
execute,
|
|
79
95
|
};
|
|
80
96
|
}
|
|
@@ -83,6 +99,17 @@ const required = (value, field) => {
|
|
|
83
99
|
throw new Error(`${field} is required`);
|
|
84
100
|
return value.trim();
|
|
85
101
|
};
|
|
102
|
+
/**
|
|
103
|
+
* Slack rejects an oversized message outright, so the length is checked here:
|
|
104
|
+
* a refusal the agent can act on beats a post that silently never happened.
|
|
105
|
+
*/
|
|
106
|
+
const messageText = (raw) => {
|
|
107
|
+
const text = required(raw, "text");
|
|
108
|
+
if (text.length > MARKDOWN_MAX) {
|
|
109
|
+
throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
|
|
110
|
+
}
|
|
111
|
+
return text;
|
|
112
|
+
};
|
|
86
113
|
const record = (raw) => raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
|
|
87
114
|
export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
88
115
|
const input = record(raw);
|
|
@@ -157,10 +184,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
157
184
|
return readMessage(deps, client, channel, required(input.ts, "ts"), asked || undefined);
|
|
158
185
|
}
|
|
159
186
|
if (input.operation === "post") {
|
|
160
|
-
const text =
|
|
161
|
-
if (text.length > MARKDOWN_MAX) {
|
|
162
|
-
throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
|
|
163
|
-
}
|
|
187
|
+
const text = messageText(input.text);
|
|
164
188
|
// Defaults to the thread we are in; `thread_ts: "none"` is the explicit
|
|
165
189
|
// way to start a new top-level message instead.
|
|
166
190
|
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
@@ -182,6 +206,20 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
182
206
|
threadTs: threadTs ?? sent.ts,
|
|
183
207
|
};
|
|
184
208
|
}
|
|
209
|
+
if (input.operation === "edit") {
|
|
210
|
+
// Explicit `ts`, for delete's reason: an edit replaces the text outright,
|
|
211
|
+
// and Slack keeps no visible record of what it said before.
|
|
212
|
+
const ts = required(input.ts, "ts");
|
|
213
|
+
const text = messageText(input.text);
|
|
214
|
+
try {
|
|
215
|
+
await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
throw new Error(explain(err));
|
|
219
|
+
}
|
|
220
|
+
deps.log(`slack tool edited ${ts} in ${channel}`);
|
|
221
|
+
return { channel, ts, edited: true };
|
|
222
|
+
}
|
|
185
223
|
if (input.operation === "delete") {
|
|
186
224
|
// Never defaulted from `here`: the thread's ts is the parent message, and
|
|
187
225
|
// "delete" with an implied target is the one mistake with no undo.
|
|
@@ -305,10 +343,12 @@ function explain(err) {
|
|
|
305
343
|
return {
|
|
306
344
|
channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
|
|
307
345
|
not_in_channel: "Pier's bot is not in that channel; someone has to invite it before it can read",
|
|
308
|
-
missing_scope: "Pier's Slack app lacks the scope for this
|
|
309
|
-
ratelimited: "Slack rate-limited
|
|
346
|
+
missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
|
|
347
|
+
ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
|
|
310
348
|
thread_not_found: "no thread with that ts in this channel",
|
|
311
349
|
cant_delete_message: "Slack only lets Pier delete what its own bot posted; a person's message has to be deleted by them",
|
|
350
|
+
cant_update_message: "Slack only lets Pier edit what its own bot posted; anyone else's message can only be replied to",
|
|
351
|
+
edit_window_closed: "Slack's edit window for that message has closed; post a correction instead of rewriting it",
|
|
312
352
|
message_not_found: "no message with that ts in this channel — a ts only means anything in the conversation it came from",
|
|
313
353
|
}[code] ?? String(err);
|
|
314
354
|
}
|
|
@@ -13,10 +13,14 @@ export class TelegramApi {
|
|
|
13
13
|
this.token = token;
|
|
14
14
|
}
|
|
15
15
|
async call(method, payload, timeoutMs = 30_000, retry = true) {
|
|
16
|
+
// An upload is the one call that is not JSON: FormData carries the bytes,
|
|
17
|
+
// and fetch sets its own multipart boundary. It is re-sendable, so the
|
|
18
|
+
// flood retry below still works on it.
|
|
19
|
+
const multipart = payload instanceof FormData;
|
|
16
20
|
const res = await fetch(`${BASE}/bot${this.token}/${method}`, {
|
|
17
21
|
method: "POST",
|
|
18
|
-
headers: { "content-type": "application/json" },
|
|
19
|
-
body: JSON.stringify(payload),
|
|
22
|
+
headers: multipart ? undefined : { "content-type": "application/json" },
|
|
23
|
+
body: multipart ? payload : JSON.stringify(payload),
|
|
20
24
|
signal: AbortSignal.timeout(timeoutMs),
|
|
21
25
|
});
|
|
22
26
|
const body = (await res.json());
|
|
@@ -41,6 +45,21 @@ export class TelegramApi {
|
|
|
41
45
|
sendMessage(payload) {
|
|
42
46
|
return this.call("sendMessage", payload);
|
|
43
47
|
}
|
|
48
|
+
/** Bytes, not a file_id or a URL: the file is local to this machine, which
|
|
49
|
+
* is the whole reason the agent could not just link it. */
|
|
50
|
+
async sendFile({ chat_id, message_thread_id, file }) {
|
|
51
|
+
const form = new FormData();
|
|
52
|
+
form.set("chat_id", String(chat_id));
|
|
53
|
+
if (message_thread_id !== undefined)
|
|
54
|
+
form.set("message_thread_id", String(message_thread_id));
|
|
55
|
+
const field = file.image ? "photo" : "document";
|
|
56
|
+
// Copied into a fresh view: a Blob part must be backed by an ArrayBuffer,
|
|
57
|
+
// and a Buffer read off disk carries the wider ArrayBufferLike type.
|
|
58
|
+
form.set(field, new Blob([new Uint8Array(file.bytes)]), file.name);
|
|
59
|
+
// A long upload on a slow link is not a hung request; 30s is the budget
|
|
60
|
+
// for a JSON call, not for megabytes.
|
|
61
|
+
await this.call(file.image ? "sendPhoto" : "sendDocument", form, 120_000);
|
|
62
|
+
}
|
|
44
63
|
async editMessage(payload) {
|
|
45
64
|
await this.call("editMessageText", payload);
|
|
46
65
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
15
15
|
import { saveInboundAll } from "../core/inbox.js";
|
|
16
16
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
17
|
+
import { sendAttachments, splitAttachments } from "./attach.js";
|
|
17
18
|
import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
|
|
18
19
|
import { logger } from "../log.js";
|
|
19
20
|
import { Chains } from "./chains.js";
|
|
@@ -413,7 +414,10 @@ export class TelegramChannel {
|
|
|
413
414
|
*/
|
|
414
415
|
async send(conversation, reply) {
|
|
415
416
|
const { chatId, topicId } = parseConversation(conversation);
|
|
416
|
-
|
|
417
|
+
// A file the agent linked is local to this machine, so the link is dead in
|
|
418
|
+
// Telegram: the bytes are uploaded instead and the label stays in the text.
|
|
419
|
+
const { text: spoken, paths } = splitAttachments(reply.text);
|
|
420
|
+
const text = spoken.trim();
|
|
417
421
|
// A turn that produced no text still posts its footer, and says which kind
|
|
418
422
|
// of nothing it was: total silence is indistinguishable from a crash, and
|
|
419
423
|
// the person waiting cannot tell. See AGENTS.md — an empty turn is still an
|
|
@@ -430,17 +434,28 @@ export class TelegramChannel {
|
|
|
430
434
|
// settleAfter: a 👀 left up because the reply failed would sit there until
|
|
431
435
|
// the stale sweep, looking like the agent is still working.
|
|
432
436
|
await this.receipts.settleAfter(conversation, async () => {
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
+
if (body.trim()) {
|
|
438
|
+
const parts = chunk(body);
|
|
439
|
+
for (const [i, part] of parts.entries()) {
|
|
440
|
+
await this.api.sendMessage({
|
|
441
|
+
chat_id: chatId,
|
|
442
|
+
message_thread_id: topicId,
|
|
443
|
+
text: part,
|
|
444
|
+
parse_mode: "HTML",
|
|
445
|
+
// Next-step buttons ride the last chunk; a click sends the label.
|
|
446
|
+
reply_markup: i === parts.length - 1 ? buttons : undefined,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Attachments follow the words, so the message that introduces them is
|
|
451
|
+
// above them; anything that could not be sent says so in the chat.
|
|
452
|
+
const lost = await sendAttachments(paths, (file) => this.api.sendFile({ chat_id: chatId, message_thread_id: topicId, file }), this.log);
|
|
453
|
+
if (lost) {
|
|
437
454
|
await this.api.sendMessage({
|
|
438
455
|
chat_id: chatId,
|
|
439
456
|
message_thread_id: topicId,
|
|
440
|
-
text:
|
|
457
|
+
text: escapeHtml(lost),
|
|
441
458
|
parse_mode: "HTML",
|
|
442
|
-
// Next-step buttons ride the last chunk; a click sends the label.
|
|
443
|
-
reply_markup: i === parts.length - 1 ? buttons : undefined,
|
|
444
459
|
});
|
|
445
460
|
}
|
|
446
461
|
});
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ Usage
|
|
|
21
21
|
pier service status what systemd thinks of it
|
|
22
22
|
pier update install the latest release and restart the service
|
|
23
23
|
pier update --check only say whether one exists
|
|
24
|
+
pier tools sync install/update the managed CLI tools (rtk, …)
|
|
24
25
|
pier restart finish running turns first, then restart the service
|
|
25
26
|
pier reload re-read channel config and recycle idle sessions
|
|
26
27
|
pier backup snapshot pier.db before a manual update
|
|
@@ -103,6 +104,9 @@ else if (command === "update") {
|
|
|
103
104
|
allowOnly(["check"], "pier update");
|
|
104
105
|
await update(values.check === true);
|
|
105
106
|
}
|
|
107
|
+
else if (command === "tools") {
|
|
108
|
+
await tools(subcommand);
|
|
109
|
+
}
|
|
106
110
|
else if (command === "restart" || command === "reload") {
|
|
107
111
|
if (subcommand)
|
|
108
112
|
fail(`unexpected argument "${subcommand}"`);
|
|
@@ -150,6 +154,36 @@ async function update(checkOnly) {
|
|
|
150
154
|
say(`npm install -g @timqi/pier@${latest}`);
|
|
151
155
|
say(`then restart Pier.`);
|
|
152
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* What the daily task runs, and what an operator can type. The setting is the
|
|
159
|
+
* instruction; this converges on it and prints what happened, one line per
|
|
160
|
+
* tool. Non-zero when anything failed — the task run is then a failed run with
|
|
161
|
+
* this text in it, which is the whole tools status surface.
|
|
162
|
+
*/
|
|
163
|
+
async function tools(action = "") {
|
|
164
|
+
if (action !== "sync") {
|
|
165
|
+
process.stderr.write(`pier tools: unknown action "${action}"\n\n${HELP}`);
|
|
166
|
+
process.exit(2);
|
|
167
|
+
}
|
|
168
|
+
allowOnly([], "pier tools sync");
|
|
169
|
+
const [{ ManagedTools }, { SettingsStore }] = await Promise.all([
|
|
170
|
+
import("./tools.js"),
|
|
171
|
+
import("./settings.js"),
|
|
172
|
+
]);
|
|
173
|
+
try {
|
|
174
|
+
// Read inside the sync's lock, not here: a sync that queued behind another
|
|
175
|
+
// one must converge on the set as it is when its turn comes.
|
|
176
|
+
const settings = new SettingsStore();
|
|
177
|
+
const report = await new ManagedTools().sync(() => settings.get());
|
|
178
|
+
say(report.summary);
|
|
179
|
+
if (report.failed)
|
|
180
|
+
process.exitCode = 1;
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
process.stderr.write(`pier: tools sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
153
187
|
/** Both are signals to the running unit: SIGUSR2 drains then exits (systemd
|
|
154
188
|
* starts the next process), SIGHUP reloads config in place (main.ts). */
|
|
155
189
|
async function signalService(command) {
|
package/dist/core/identity.js
CHANGED
|
@@ -90,3 +90,21 @@ export function splitSpeaker(text) {
|
|
|
90
90
|
text: text.slice(m[0].length),
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* A session titled by its first prompt inherits that prompt's header, and the
|
|
95
|
+
* header is for the model: anything a person reads — a list row, a session
|
|
96
|
+
* header, a notification on a phone — would say "operator: …" on every session
|
|
97
|
+
* the workbench ever opened. So the speaker comes off and what they said is
|
|
98
|
+
* the title. Here rather than in a UI module because the push notification
|
|
99
|
+
* needs the same answer and a second copy of this would drift (AGENTS.md §3).
|
|
100
|
+
*/
|
|
101
|
+
export function readableTitle(title) {
|
|
102
|
+
if (!title)
|
|
103
|
+
return title;
|
|
104
|
+
const { text } = splitSpeaker(title);
|
|
105
|
+
// No header — the title is what the person typed, and reflowing it would
|
|
106
|
+
// change what the sidebar's search is matching against for nothing.
|
|
107
|
+
if (text === title)
|
|
108
|
+
return title;
|
|
109
|
+
return text.replace(/\s+/g, " ").trim() || title;
|
|
110
|
+
}
|
|
@@ -41,7 +41,9 @@ export const fileMarker = (path) => `[${path.split("/").pop() ?? "file"}](file:/
|
|
|
41
41
|
/**
|
|
42
42
|
* The conversation-visible line for an attachment that never made it (5b: a
|
|
43
43
|
* failed download must not look like no attachment). Plain text on purpose —
|
|
44
|
-
* not a link — so every surface renders it as the words it is.
|
|
44
|
+
* not a link — so every surface renders it as the words it is. Both
|
|
45
|
+
* directions: an inbound file Pier could not fetch and an outbound one it
|
|
46
|
+
* could not upload (channels/attach.ts) are the same fact to the reader.
|
|
45
47
|
*/
|
|
46
48
|
export const lostMarker = (name, reason) => `[attachment lost: ${name} — ${reason}]`;
|
|
47
49
|
// A whole line that is one `[name](file:///…)` link — what fileMarker emits.
|
package/dist/core/reply.js
CHANGED
|
@@ -52,7 +52,8 @@ new day — so the last one still applies; a gap alone shows as time only, like
|
|
|
52
52
|
export function surfacePrompt(instance) {
|
|
53
53
|
const reach = instance.publicUrl
|
|
54
54
|
? `Address: ${instance.publicUrl} — a board's link is that plus ` +
|
|
55
|
-
"`/boards/<slug>/`, or `/p/<slug>/` once published
|
|
55
|
+
"`/boards/<slug>/`, or `/p/<slug>-<token>/` once published, where `token` " +
|
|
56
|
+
"is the random field the manifest carries beside `public`."
|
|
56
57
|
: "No public address is configured (the user sets one in Console → Settings), " +
|
|
57
58
|
"so give paths and never guess a host.";
|
|
58
59
|
return `${REPLY_SURFACE_PROMPT}
|