@timqi/pier 0.0.9 โ 0.0.16
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 +7 -1
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +190 -31
- 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/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 +72 -0
- package/dist/db.js +78 -0
- package/dist/extensions/index.js +5 -2
- package/dist/extensions/web/artifacts.js +7 -2
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +33 -8
- package/dist/limits.js +14 -0
- package/dist/main.js +47 -6
- package/dist/paths.js +6 -1
- package/dist/settings.js +44 -0
- package/dist/tasks/agent.js +23 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +60 -12
- package/dist/tasks/execution.js +9 -1
- package/dist/tasks/groups.js +8 -4
- package/dist/tasks/messages.js +10 -2
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +7 -2
- package/dist/tasks/service.js +22 -6
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/tool.js +0 -12
- package/dist/tasks/types.js +4 -0
- 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 +165 -36
- package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js โ ghostty-web-C4ivXTBE.js} +1 -1
- package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
- package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +22 -7
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +145 -64
- package/dist/web/session-state.js +33 -51
- package/dist/web/types.js +5 -0
- package/docs/deploy.md +12 -3
- 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-DNCJJRSS.js +0 -91
- package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
|
@@ -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}
|
package/dist/core/router.js
CHANGED
|
@@ -224,6 +224,15 @@ export class Router {
|
|
|
224
224
|
});
|
|
225
225
|
});
|
|
226
226
|
}
|
|
227
|
+
// A queued message with no turn left to deliver it. `decide` reads the
|
|
228
|
+
// state once, so a steer chosen against a turn that ends before the call
|
|
229
|
+
// lands sits in Pi's queue until some *later* turn reads it โ on IM that
|
|
230
|
+
// is indistinguishable from the message never arriving (ยง5b). Pi drains
|
|
231
|
+
// its own queues up to the agent_end handler, so a non-empty queue on an
|
|
232
|
+
// idle session is exactly the message that missed that window.
|
|
233
|
+
if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
|
|
234
|
+
this.promoteQueued(session, key);
|
|
235
|
+
}
|
|
227
236
|
// Every turn-end reaches the channel, empty text included: an adapter's
|
|
228
237
|
// per-turn UI (Telegram's ๐ receipts) is retired here, and a turn that
|
|
229
238
|
// settled with nothing to say still has to settle.
|
|
@@ -245,9 +254,69 @@ export class Router {
|
|
|
245
254
|
unsubscribe,
|
|
246
255
|
});
|
|
247
256
|
}
|
|
257
|
+
/** Sessions whose queue is being promoted right now. `clearQueue` and the
|
|
258
|
+
* prompt that follows it both emit queue-state of their own, so without
|
|
259
|
+
* this the handler would re-enter on its own effects. */
|
|
260
|
+
promoting = new Set();
|
|
261
|
+
/**
|
|
262
|
+
* Turn a stranded queue into the turn it was waiting for. Not routed through
|
|
263
|
+
* `dispatch`: the text was prefixed when it was first dispatched
|
|
264
|
+
* (identity.ts), and sending it back through would head it a second time.
|
|
265
|
+
*
|
|
266
|
+
* Only ever reached from a queue-state event, never from a turn ending: Pi
|
|
267
|
+
* leaves the queue alone on `abort()`, so promoting on idle would make /stop
|
|
268
|
+
* start the very turn it was asked to stop. Recovering *those* messages stays
|
|
269
|
+
* the web's recall route, which hands them back to the composer.
|
|
270
|
+
*/
|
|
271
|
+
promoteQueued(session, key) {
|
|
272
|
+
if (session.state !== "idle" || this.promoting.has(session.id))
|
|
273
|
+
return;
|
|
274
|
+
this.promoting.add(session.id);
|
|
275
|
+
void (async () => {
|
|
276
|
+
try {
|
|
277
|
+
// Re-read: a turn may have started since the event, and it will drain
|
|
278
|
+
// the queue itself โ clearing it here would take the messages out of it.
|
|
279
|
+
if (session.state !== "idle")
|
|
280
|
+
return;
|
|
281
|
+
const { steering, followUp } = await session.clearQueue();
|
|
282
|
+
const text = [...steering, ...followUp].join("\n").trim();
|
|
283
|
+
if (!text)
|
|
284
|
+
return;
|
|
285
|
+
// A drain is "no new turns", and this would be one. Told to the
|
|
286
|
+
// conversation rather than dropped, because the message is now out of
|
|
287
|
+
// the queue and nothing else would ever mention it (ยง5b).
|
|
288
|
+
if (this.draining) {
|
|
289
|
+
this.report(session.id, key, `queued message not taken โ Pier is restarting; send it again: ${truncate(text)}`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
log.info(`promoting ${String(steering.length + followUp.length)} queued message(s) โ session ${session.id}`);
|
|
293
|
+
await session.prompt(text);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
this.report(session.id, key, `delivering the queued messages failed: ${String(err)}`);
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
this.promoting.delete(session.id);
|
|
300
|
+
}
|
|
301
|
+
})();
|
|
302
|
+
}
|
|
248
303
|
async abort(sessionId) {
|
|
249
304
|
await this.bySession.get(sessionId)?.session.abort();
|
|
250
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Drop what this session was told about who is speaking, so the next message
|
|
308
|
+
* carries a full header again.
|
|
309
|
+
*
|
|
310
|
+
* For the surfaces that take a prefixed message back *out* of the context it
|
|
311
|
+
* was counted into โ a recalled queue, a rewound turn. The tracker's whole
|
|
312
|
+
* job is "the model has already been told" (identity.ts), and a header that
|
|
313
|
+
* never reached the model, or reached it and was then rewound away, makes
|
|
314
|
+
* every later message from that speaker unattributed in a group chat. One
|
|
315
|
+
* redundant header is the same price eviction already pays.
|
|
316
|
+
*/
|
|
317
|
+
forgetSender(sessionId) {
|
|
318
|
+
this.senders.forget(sessionId);
|
|
319
|
+
}
|
|
251
320
|
/** Refuse new work from every surface; in-flight turns keep running. */
|
|
252
321
|
beginDrain() {
|
|
253
322
|
this.draining = true;
|
|
@@ -384,6 +453,9 @@ export class Router {
|
|
|
384
453
|
// Turn outcomes flow through the event stream; a rejected call surfaces
|
|
385
454
|
// there too, never as a thrown exception across the seam.
|
|
386
455
|
session[action](prompt).catch((err) => {
|
|
456
|
+
// The header was counted as delivered a line above; this message never
|
|
457
|
+
// arrived, so the next one from this speaker must carry it again.
|
|
458
|
+
this.senders.forget(session.id);
|
|
387
459
|
this.report(session.id, msg.key, String(err));
|
|
388
460
|
});
|
|
389
461
|
return { sessionId: session.id };
|
package/dist/db.js
CHANGED
|
@@ -175,6 +175,84 @@ const MIGRATIONS = [
|
|
|
175
175
|
-- every one of its rows because a project is a cwd, not a table.
|
|
176
176
|
ALTER TABLE session_state ADD COLUMN sort INTEGER;
|
|
177
177
|
ALTER TABLE session_state ADD COLUMN project_sort INTEGER;
|
|
178
|
+
`,
|
|
179
|
+
// 7 โ the session listing, so a transcript is read once (agent/listing.ts).
|
|
180
|
+
`
|
|
181
|
+
-- One row per session file. (size, mtime) is what makes the row usable
|
|
182
|
+
-- without opening the file; parsed_bytes is where reading resumes when it
|
|
183
|
+
-- grew, and is always a line boundary. Derived from disk and disposable: a
|
|
184
|
+
-- deleted row costs one re-read, never a fact.
|
|
185
|
+
CREATE TABLE session_index (
|
|
186
|
+
path TEXT PRIMARY KEY,
|
|
187
|
+
id TEXT NOT NULL,
|
|
188
|
+
cwd TEXT NOT NULL,
|
|
189
|
+
created_at INTEGER NOT NULL,
|
|
190
|
+
name TEXT,
|
|
191
|
+
first_message TEXT,
|
|
192
|
+
size INTEGER NOT NULL,
|
|
193
|
+
mtime INTEGER NOT NULL,
|
|
194
|
+
parsed_bytes INTEGER NOT NULL
|
|
195
|
+
);
|
|
196
|
+
`,
|
|
197
|
+
// 8 โ Projects holds a working set: what is warm, plus what is kept.
|
|
198
|
+
`
|
|
199
|
+
-- Membership was permanent, so every throwaway session stayed in the rail
|
|
200
|
+
-- until someone removed it by hand. last_active is the lease: the end of a
|
|
201
|
+
-- turn renews it, and web/session-state.ts stops listing a row that ran out.
|
|
202
|
+
-- kept opts one row out of expiry entirely โ what the pin control now means.
|
|
203
|
+
ALTER TABLE session_state ADD COLUMN kept INTEGER NOT NULL DEFAULT 0;
|
|
204
|
+
ALTER TABLE session_state ADD COLUMN last_active INTEGER;
|
|
205
|
+
-- Left NULL on purpose: the honest value is when the transcript was last
|
|
206
|
+
-- written, which only a listing knows. web/server.ts pays one for a database
|
|
207
|
+
-- carrying rows without it, the same gate the pin backfill already uses, so
|
|
208
|
+
-- the first rail after an upgrade is dated by use and not by creation.
|
|
209
|
+
`,
|
|
210
|
+
// 9 โ the summary a transcript already carries is read, not mirrored.
|
|
211
|
+
`
|
|
212
|
+
-- Dropped rather than left unread: a column nobody writes still answers when
|
|
213
|
+
-- somebody selects it, and the next reader has no way to tell a stale title
|
|
214
|
+
-- from a current one. The pre-migration backup beside the database is the
|
|
215
|
+
-- way back, not a row of fossils. cwd stays โ it is the key a project's
|
|
216
|
+
-- manual place is stamped on, and it never changes for a session.
|
|
217
|
+
ALTER TABLE session_state DROP COLUMN title;
|
|
218
|
+
ALTER TABLE session_state DROP COLUMN created_at;
|
|
219
|
+
ALTER TABLE session_state DROP COLUMN last_active;
|
|
220
|
+
`,
|
|
221
|
+
// 10 โ taking a session into Projects is itself an act, and it is dated.
|
|
222
|
+
`
|
|
223
|
+
-- When a hand last put this session in Projects (pin, or a keep toggle).
|
|
224
|
+
-- Not the mirror migration 9 removed: last_active was a copy of a fact the
|
|
225
|
+
-- transcript owns, while this one exists nowhere else โ pinning a cold
|
|
226
|
+
-- session back is a statement that it is warm again, and without a record of
|
|
227
|
+
-- *when* it was made the row is dropped by the same read that drew it.
|
|
228
|
+
-- NULL for every row that predates this: never pinned within a lease.
|
|
229
|
+
ALTER TABLE session_state ADD COLUMN pinned_at INTEGER;
|
|
230
|
+
`,
|
|
231
|
+
// 11 โ Projects holds what a hand put there, for as long as the hand says.
|
|
232
|
+
`
|
|
233
|
+
-- The lease is gone, so both of its columns are. It expired nothing: a row
|
|
234
|
+
-- it dropped kept its transcript, its place and its ownership, and one more
|
|
235
|
+
-- turn brought it back โ so what it actually did was hide rows nobody asked
|
|
236
|
+
-- it to hide, and kept existed only to opt out of that. On the instance this
|
|
237
|
+
-- was decided on, the lease had never dropped a row: 20 pinned sessions,
|
|
238
|
+
-- none past seven days, one kept. Removing a row from Projects is the โ on
|
|
239
|
+
-- the row, and it stays the only way out.
|
|
240
|
+
ALTER TABLE session_state DROP COLUMN kept;
|
|
241
|
+
ALTER TABLE session_state DROP COLUMN pinned_at;
|
|
242
|
+
`,
|
|
243
|
+
// 12 โ one row is how two processes take turns (src/tools.ts).
|
|
244
|
+
`
|
|
245
|
+
-- The tools sync, held across processes: the token says who holds it, the
|
|
246
|
+
-- heartbeat says they are still alive. Both processes already open this
|
|
247
|
+
-- database, and BEGIN IMMEDIATE is real mutual exclusion โ a lock file with
|
|
248
|
+
-- a pid in it is neither, which is what this replaces. One row, because
|
|
249
|
+
-- there is one thing to serialize; the second lock can bring its own table
|
|
250
|
+
-- and its own reason for existing.
|
|
251
|
+
CREATE TABLE tools_sync_lock (
|
|
252
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
253
|
+
token TEXT NOT NULL,
|
|
254
|
+
heartbeat_at INTEGER NOT NULL
|
|
255
|
+
);
|
|
178
256
|
`,
|
|
179
257
|
];
|
|
180
258
|
let shared;
|
package/dist/extensions/index.js
CHANGED
|
@@ -22,11 +22,14 @@ export const BUNDLED = [
|
|
|
22
22
|
factory: web,
|
|
23
23
|
},
|
|
24
24
|
];
|
|
25
|
-
/** The catalog a surface may show: no Pi types, nothing it cannot render.
|
|
25
|
+
/** The catalog a surface may show: no Pi types, nothing it cannot render.
|
|
26
|
+
* Half of one list; src/tools.ts has the other. */
|
|
26
27
|
export const bundledInfo = (enabled) => BUNDLED.map(({ name, summary, tools }) => ({
|
|
28
|
+
source: "bundled",
|
|
29
|
+
kind: "extension",
|
|
27
30
|
name,
|
|
28
31
|
summary,
|
|
29
|
-
tools,
|
|
32
|
+
adds: tools,
|
|
30
33
|
enabled: enabled.includes(name),
|
|
31
34
|
}));
|
|
32
35
|
/** The enabled ones as Pi inline extensions; unknown names are not ours. */
|
|
@@ -34,11 +34,16 @@ function displayUrl(url) {
|
|
|
34
34
|
}
|
|
35
35
|
return redacted.toString();
|
|
36
36
|
}
|
|
37
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
37
38
|
export async function saveArtifact(url, text, retrievedAt) {
|
|
38
39
|
await mkdir(ARTIFACT_DIR, { recursive: true, mode: 0o700 });
|
|
39
40
|
const host = url.hostname.replace(/[^a-zA-Z0-9.-]+/g, "-").slice(0, 80) || "page";
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
// The URL alone is not the file's identity: a page fetched again is a
|
|
42
|
+
// different document, and keying on the URL overwrote the copy an older
|
|
43
|
+
// transcript's `artifactPath` still points at โ the one promise this file
|
|
44
|
+
// makes. Content decides, so a refetch that changed writes a new file and one
|
|
45
|
+
// that did not costs nothing.
|
|
46
|
+
const path = join(ARTIFACT_DIR, `${host}-${digest(url.toString())}-${digest(text)}.md`);
|
|
42
47
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
43
48
|
const header = [
|
|
44
49
|
`Source: ${displayUrl(url)}`,
|