@timqi/pier 0.0.17 → 0.0.19
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/dist/agent/events.js +16 -4
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +28 -2
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/core/hub.js +14 -4
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +64 -0
- package/dist/db.js +44 -0
- package/dist/main.js +1 -0
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +14 -1
- package/dist/tasks/definitions.js +3 -1
- package/dist/tasks/messages.js +1 -2
- package/dist/tasks/service.js +5 -0
- package/dist/tasks/store.js +44 -12
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
- package/dist/web/public/assets/code-DjWvqL94.js +1 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-D8CLFi_l.js} +1 -1
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -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-BFzmvpGX.js +78 -0
- package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
- package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
- package/dist/web/public/assets/{index-CtR-Ob4T.css → index-yNocr797.css} +1 -1
- package/dist/web/public/assets/index-yNocr797.css.br +0 -0
- package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +104 -23
- package/dist/web/terminal.js +2 -7
- package/package.json +1 -1
- package/skills/pier-slack/SKILL.md +4 -16
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
package/dist/agent/events.js
CHANGED
|
@@ -184,13 +184,25 @@ export function toSessionEvents(e) {
|
|
|
184
184
|
case "agent_start":
|
|
185
185
|
return [{ type: "state", state: "streaming" }, { type: "turn-start" }];
|
|
186
186
|
case "agent_end": {
|
|
187
|
+
// Pi retries a retryable provider error itself and emits one agent_end
|
|
188
|
+
// per attempt. Only the last one ends the turn: translating the others
|
|
189
|
+
// posts a reply and an error per attempt for a failure Pi is still
|
|
190
|
+
// recovering from, and an `idle` the session is not in.
|
|
191
|
+
if (e.willRetry)
|
|
192
|
+
return [];
|
|
187
193
|
const final = lastAssistant(e.messages);
|
|
194
|
+
// A turn can end without the model ever answering. Carried twice on
|
|
195
|
+
// purpose: on turn-end because it is *how this turn ended*, which is what
|
|
196
|
+
// a task run settles on (tasks/agent.ts), and as the error event that is
|
|
197
|
+
// already every chat surface's failure path (core/router.ts).
|
|
198
|
+
const failure = final?.stopReason === "error"
|
|
199
|
+
? final.errorMessage || "unknown agent error"
|
|
200
|
+
: undefined;
|
|
188
201
|
const out = [
|
|
189
|
-
{ type: "turn-end", text: textOf(final?.content) },
|
|
202
|
+
{ type: "turn-end", text: textOf(final?.content), ...(failure ? { error: failure } : {}) },
|
|
190
203
|
];
|
|
191
|
-
if (
|
|
192
|
-
out.push({ type: "error", message:
|
|
193
|
-
}
|
|
204
|
+
if (failure)
|
|
205
|
+
out.push({ type: "error", message: failure });
|
|
194
206
|
out.push({ type: "state", state: "idle" });
|
|
195
207
|
return out;
|
|
196
208
|
}
|
package/dist/agent/listing.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// incremental.
|
|
17
17
|
import { createReadStream, promises as fs } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
-
import { pierDb } from "../db.js";
|
|
19
|
+
import { pierDb, statements, transact } from "../db.js";
|
|
20
20
|
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
21
21
|
import { logger } from "../log.js";
|
|
22
22
|
import { defaultAgentDir } from "./config.js";
|
|
@@ -75,6 +75,7 @@ function fold(acc, line) {
|
|
|
75
75
|
export class IndexedListing {
|
|
76
76
|
dir;
|
|
77
77
|
#db;
|
|
78
|
+
#statements;
|
|
78
79
|
constructor(dir = join(defaultAgentDir(), "sessions"), db) {
|
|
79
80
|
this.dir = dir;
|
|
80
81
|
this.#db = db;
|
|
@@ -85,9 +86,12 @@ export class IndexedListing {
|
|
|
85
86
|
#store() {
|
|
86
87
|
return (this.#db ??= pierDb());
|
|
87
88
|
}
|
|
89
|
+
/** The scan's three statements, compiled on the first scan and not again. */
|
|
90
|
+
#sql() {
|
|
91
|
+
return (this.#statements ??= statements(this.#store()));
|
|
92
|
+
}
|
|
88
93
|
async scan() {
|
|
89
|
-
const
|
|
90
|
-
const known = new Map(db.prepare("SELECT * FROM session_index").all().map((row) => [row.path, row]));
|
|
94
|
+
const known = new Map(this.#sql()("SELECT * FROM session_index").all().map((row) => [row.path, row]));
|
|
91
95
|
const records = [];
|
|
92
96
|
const write = [];
|
|
93
97
|
for (const file of await this.#files()) {
|
|
@@ -228,26 +232,21 @@ export class IndexedListing {
|
|
|
228
232
|
if (!rows.length && !gone.length)
|
|
229
233
|
return;
|
|
230
234
|
const db = this.#store();
|
|
231
|
-
const
|
|
235
|
+
const sql = this.#sql();
|
|
236
|
+
const upsert = sql(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
|
|
232
237
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
233
238
|
ON CONFLICT(path) DO UPDATE SET
|
|
234
239
|
id = excluded.id, cwd = excluded.cwd, created_at = excluded.created_at,
|
|
235
240
|
name = excluded.name, first_message = excluded.first_message,
|
|
236
241
|
size = excluded.size, mtime = excluded.mtime,
|
|
237
242
|
parsed_bytes = excluded.parsed_bytes`);
|
|
238
|
-
const drop =
|
|
239
|
-
db
|
|
240
|
-
try {
|
|
243
|
+
const drop = sql("DELETE FROM session_index WHERE path = ?");
|
|
244
|
+
transact(db, () => {
|
|
241
245
|
for (const r of rows) {
|
|
242
246
|
upsert.run(r.path, r.id, r.cwd, r.created_at, r.name, r.first_message, r.size, r.mtime, r.parsed_bytes);
|
|
243
247
|
}
|
|
244
248
|
for (const path of gone)
|
|
245
249
|
drop.run(path);
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
catch (err) {
|
|
249
|
-
db.exec("ROLLBACK");
|
|
250
|
-
throw err;
|
|
251
|
-
}
|
|
250
|
+
});
|
|
252
251
|
}
|
|
253
252
|
}
|
package/dist/agent/pi.js
CHANGED
|
@@ -90,6 +90,17 @@ export const standDownShadowed = (base) => {
|
|
|
90
90
|
}),
|
|
91
91
|
};
|
|
92
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* A bundled skill stands down with the tool it documents. A skill's
|
|
95
|
+
* description is resident in every prompt, so one pointing at a tool this
|
|
96
|
+
* session was not given is both a cost and a route the agent cannot take.
|
|
97
|
+
*/
|
|
98
|
+
export const standDownUndocumented = (tools, skills) => {
|
|
99
|
+
const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
|
|
100
|
+
if (!gone.size)
|
|
101
|
+
return skills;
|
|
102
|
+
return skills.filter((skill) => !gone.has(skill.name));
|
|
103
|
+
};
|
|
93
104
|
export class PiSession {
|
|
94
105
|
pi;
|
|
95
106
|
pinned;
|
|
@@ -285,8 +296,19 @@ export class PiSession {
|
|
|
285
296
|
return this.pi.abort();
|
|
286
297
|
}
|
|
287
298
|
subscribe(fn) {
|
|
299
|
+
let retryPending = false;
|
|
288
300
|
return this.pi.subscribe((event) => {
|
|
289
|
-
|
|
301
|
+
const piEvent = event;
|
|
302
|
+
if (piEvent.type === "agent_end")
|
|
303
|
+
retryPending = piEvent.willRetry === true;
|
|
304
|
+
if (piEvent.type === "agent_settled" && retryPending) {
|
|
305
|
+
// Aborting Pi during retry backoff produces no final agent_end.
|
|
306
|
+
retryPending = false;
|
|
307
|
+
fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
|
|
308
|
+
fn({ type: "state", state: "idle" });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
for (const payload of toSessionEvents(piEvent)) {
|
|
290
312
|
fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
|
|
291
313
|
}
|
|
292
314
|
});
|
|
@@ -553,6 +575,10 @@ export class PiAgentFactory {
|
|
|
553
575
|
// Pi's generic default, preserving the user's later instruction layer.
|
|
554
576
|
systemPromptOverride: pierSystemPrompt,
|
|
555
577
|
additionalSkillPaths: this.skillPaths,
|
|
578
|
+
skillsOverride: (base) => ({
|
|
579
|
+
...base,
|
|
580
|
+
skills: standDownUndocumented(this.extraTools, base.skills),
|
|
581
|
+
}),
|
|
556
582
|
extensionFactories: [
|
|
557
583
|
{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
|
|
558
584
|
...inlineExtensions(this.enabledExtensions()),
|
|
@@ -688,7 +714,7 @@ export class PiAgentFactory {
|
|
|
688
714
|
* session this factory opened. The miss is what earns a fresh scan, because
|
|
689
715
|
* callers read it as permission to start a replacement session
|
|
690
716
|
* (channels/conversations.ts), which costs a conversation its history, or as
|
|
691
|
-
* a session that no longer exists (tasks/, web/
|
|
717
|
+
* a session that no longer exists (tasks/, web/server.ts). `reused` is how we
|
|
692
718
|
* know a scan is owed: same entry back, same disk state. */
|
|
693
719
|
async locate(sessionId) {
|
|
694
720
|
const find = (infos) => infos.find((s) => s.id === sessionId);
|
package/dist/boards/boards.js
CHANGED
|
@@ -122,15 +122,16 @@ export async function listBoards(dir) {
|
|
|
122
122
|
catch {
|
|
123
123
|
return []; // no boards yet
|
|
124
124
|
}
|
|
125
|
-
|
|
126
|
-
for
|
|
125
|
+
// One board's manifest says nothing about the next one's, so the scan waits
|
|
126
|
+
// once for all of them rather than once per board.
|
|
127
|
+
const boards = await Promise.all(entries.sort().map(async (slug) => {
|
|
127
128
|
const manifest = await readManifest(dir, slug);
|
|
128
129
|
if (!manifest)
|
|
129
|
-
|
|
130
|
+
return null;
|
|
130
131
|
const { title, description, sessions, public: isPublic, token } = manifest;
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
return boards;
|
|
132
|
+
return { slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) };
|
|
133
|
+
}));
|
|
134
|
+
return boards.filter((board) => board !== null);
|
|
134
135
|
}
|
|
135
136
|
/** Containment, not normalization: the resolved realpath must sit inside the
|
|
136
137
|
* board's own site dir or nothing is served. */
|
package/dist/channels/attach.js
CHANGED
|
@@ -65,7 +65,14 @@ export async function sendAttachments(paths, upload, log) {
|
|
|
65
65
|
log(`attachment ${path} not sent: ${reason}`);
|
|
66
66
|
lost.push(lostMarker(nameOf(path), reason));
|
|
67
67
|
};
|
|
68
|
-
|
|
68
|
+
const taken = paths.slice(0, MAX_ATTACHMENTS);
|
|
69
|
+
// Five files that know nothing about each other cost one round trip, not
|
|
70
|
+
// five. Two phases so the platform still receives them in the order the
|
|
71
|
+
// turn linked them, and each failure is kept in its own slot — `lost` reads
|
|
72
|
+
// in link order whichever upload finished first.
|
|
73
|
+
const reasons = [];
|
|
74
|
+
const reasonOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
75
|
+
const files = await Promise.all(taken.map(async (path, i) => {
|
|
69
76
|
try {
|
|
70
77
|
const info = await stat(path);
|
|
71
78
|
if (!info.isFile())
|
|
@@ -74,12 +81,19 @@ export async function sendAttachments(paths, upload, log) {
|
|
|
74
81
|
throw new Error(`too large (>${MAX_ATTACH_BYTES} bytes)`);
|
|
75
82
|
const name = nameOf(path);
|
|
76
83
|
const ext = extname(name).slice(1).toLowerCase();
|
|
77
|
-
|
|
84
|
+
return { name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) };
|
|
78
85
|
}
|
|
79
86
|
catch (err) {
|
|
80
|
-
|
|
87
|
+
reasons[i] = reasonOf(err);
|
|
88
|
+
return undefined;
|
|
81
89
|
}
|
|
82
|
-
}
|
|
90
|
+
}));
|
|
91
|
+
await Promise.all(files.map((file, i) => file ? upload(file).catch((err) => void (reasons[i] = reasonOf(err))) : undefined));
|
|
92
|
+
taken.forEach((path, i) => {
|
|
93
|
+
const reason = reasons[i];
|
|
94
|
+
if (reason !== undefined)
|
|
95
|
+
fail(path, reason);
|
|
96
|
+
});
|
|
83
97
|
for (const path of paths.slice(MAX_ATTACHMENTS)) {
|
|
84
98
|
fail(path, `more than ${MAX_ATTACHMENTS} files in one turn`);
|
|
85
99
|
}
|
package/dist/channels/config.js
CHANGED
|
@@ -73,13 +73,18 @@ export class ChannelStore {
|
|
|
73
73
|
* discovery is passive. A new chat copies the platform defaults and owns
|
|
74
74
|
* them from then on — the mention and bind gates are what keep it harmless
|
|
75
75
|
* until an operator configures it.
|
|
76
|
+
*
|
|
77
|
+
* Telegram gets a chat's name free with every update, so it discovers on
|
|
78
|
+
* every message: the "already known, unchanged" answer is read off the
|
|
79
|
+
* cached document and costs no clone. Only a real change pays for one.
|
|
76
80
|
*/
|
|
77
81
|
discoverChat(platform, chat) {
|
|
82
|
+
const cached = this.cached(platform).chats.find((c) => c.id === chat.id);
|
|
83
|
+
if (cached && cached.name === chat.name && cached.kind === chat.kind)
|
|
84
|
+
return;
|
|
78
85
|
const config = this.get(platform);
|
|
79
86
|
const known = config.chats.find((c) => c.id === chat.id);
|
|
80
87
|
if (known) {
|
|
81
|
-
if (known.name === chat.name && known.kind === chat.kind)
|
|
82
|
-
return;
|
|
83
88
|
known.name = chat.name || known.name;
|
|
84
89
|
known.kind = chat.kind;
|
|
85
90
|
}
|
package/dist/channels/dedup.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// the second copy had already appeared and the map has an invariant that is
|
|
5
5
|
// easy to lose on a rewrite: it is fed by every message in every chat the bot
|
|
6
6
|
// is in, so it must be bounded and time-limited, never grow-only.
|
|
7
|
+
/** Fraction of `max` a full map is cut back to, so the eviction walk is paid
|
|
8
|
+
* once per that many messages instead of once per message. */
|
|
9
|
+
const KEEP = 0.9;
|
|
7
10
|
export class Dedup {
|
|
8
11
|
log;
|
|
9
12
|
ttlMs;
|
|
@@ -28,8 +31,13 @@ export class Dedup {
|
|
|
28
31
|
// is a forgotten id under extreme load (a redelivery slips through,
|
|
29
32
|
// which downstream handling tolerates); unbounded memory is worse.
|
|
30
33
|
// Map iterates in insertion order, so the front is the oldest.
|
|
34
|
+
//
|
|
35
|
+
// Evicting down to `KEEP` rather than to exactly `max` is what makes
|
|
36
|
+
// the walk amortized: at the bound, freeing one slot per message meant
|
|
37
|
+
// re-walking the whole map on every message from then on.
|
|
38
|
+
const keep = Math.floor(this.max * KEEP);
|
|
31
39
|
for (const [id] of this.seen) {
|
|
32
|
-
if (this.seen.size
|
|
40
|
+
if (this.seen.size <= keep)
|
|
33
41
|
break;
|
|
34
42
|
this.seen.delete(id);
|
|
35
43
|
}
|
package/dist/channels/lark.js
CHANGED
|
@@ -44,7 +44,6 @@ const DRAIN_TIMEOUT_MS = 5000;
|
|
|
44
44
|
/** How long a delivered event id is remembered, against redelivery. */
|
|
45
45
|
const DEDUP_TTL_MS = 5 * 60_000;
|
|
46
46
|
const DEDUP_MAX = 2000;
|
|
47
|
-
const SWEEP_EVERY_MS = 60_000;
|
|
48
47
|
/**
|
|
49
48
|
* A Lark conversation is always `<chatId>/<rootMessageId>` — the thread is
|
|
50
49
|
* the session. This pair is the only definition of the format; control.ts
|
|
@@ -87,7 +86,6 @@ export class LarkChannel {
|
|
|
87
86
|
out;
|
|
88
87
|
socket;
|
|
89
88
|
running = false;
|
|
90
|
-
sweptAt = 0;
|
|
91
89
|
constructor(deps) {
|
|
92
90
|
this.deps = deps;
|
|
93
91
|
const config = deps.store.get("lark");
|
|
@@ -143,10 +141,8 @@ export class LarkChannel {
|
|
|
143
141
|
onEvent(event, onMessage) {
|
|
144
142
|
if (!this.running)
|
|
145
143
|
return;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
void this.receipts.sweep();
|
|
149
|
-
}
|
|
144
|
+
// Asked on every event, throttled inside receipts.ts.
|
|
145
|
+
void this.receipts.sweep();
|
|
150
146
|
// Our own echo or another app's message.
|
|
151
147
|
if (event.senderType === "app")
|
|
152
148
|
return;
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
// it finds at startup (nothing in memory can be its own yet), and sweeps its
|
|
9
9
|
// own stragglers on a timer.
|
|
10
10
|
import { pierDb } from "../db.js";
|
|
11
|
+
/**
|
|
12
|
+
* How often the straggler sweep may really run. Adapters ask on the inbound
|
|
13
|
+
* path — per envelope, or per `getUpdates` round trip — and the books only
|
|
14
|
+
* change on the scale of `staleMs`, so a busy chat would otherwise run this
|
|
15
|
+
* query hundreds of times a minute. Throttled here rather than in each
|
|
16
|
+
* adapter, which is where the same timestamp had been copied twice already.
|
|
17
|
+
*/
|
|
18
|
+
const SWEEP_EVERY_MS = 60_000;
|
|
11
19
|
const toReceipt = (row) => ({
|
|
12
20
|
conversationId: row.conversation_id,
|
|
13
21
|
chatId: row.chat_id,
|
|
@@ -68,6 +76,7 @@ export class Receipts {
|
|
|
68
76
|
staleMs;
|
|
69
77
|
/** In-flight `setReaction` per marked message. Only this process's own. */
|
|
70
78
|
applying = new Map();
|
|
79
|
+
sweptAt = 0;
|
|
71
80
|
constructor(api, ledger, log, emoji,
|
|
72
81
|
/** After this, a receipt's turn is assumed never to settle. */
|
|
73
82
|
staleMs) {
|
|
@@ -106,14 +115,20 @@ export class Receipts {
|
|
|
106
115
|
* ours yet — and past `staleMs` a receipt's turn is never going to settle.
|
|
107
116
|
*/
|
|
108
117
|
sweep(all = false) {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
// `all` is the startup sweep: it takes everything, so it is never skipped.
|
|
120
|
+
if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
this.sweptAt = now;
|
|
109
123
|
return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
|
|
110
124
|
}
|
|
111
125
|
async clear(receipts) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
126
|
+
// Wait together, then launch clears in booking order: if one apply is slow,
|
|
127
|
+
// it must not let a later receipt clear first.
|
|
128
|
+
await Promise.all(receipts.map(({ chatId, messageId }) => this.applying.get(`${chatId}:${messageId}`)));
|
|
129
|
+
for (const { chatId, messageId } of receipts)
|
|
130
|
+
this.applying.delete(`${chatId}:${messageId}`);
|
|
131
|
+
await Promise.all(receipts.map(({ chatId, messageId }) => this.api.setReaction(chatId, messageId, null)
|
|
132
|
+
.catch((err) => this.log(`reaction clear failed: ${String(err)}`))));
|
|
118
133
|
}
|
|
119
134
|
}
|
|
@@ -27,19 +27,25 @@ const tsToNumber = (ts) => Number(ts);
|
|
|
27
27
|
const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
|
|
28
28
|
/** Minute precision in the transcript: the exact time is in the `ts` beside it. */
|
|
29
29
|
const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
|
|
30
|
+
/** Epoch seconds at the year 2100: past this, the caller meant milliseconds. */
|
|
31
|
+
const MAX_SECONDS = 4_102_444_800;
|
|
30
32
|
/** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
|
|
31
33
|
export function toTs(value) {
|
|
32
34
|
if (value === undefined || value === "")
|
|
33
35
|
return undefined;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const parsed = Date.parse(trimmed);
|
|
40
|
-
if (Number.isNaN(parsed))
|
|
36
|
+
const raw = typeof value === "number" ? String(value) : value.trim();
|
|
37
|
+
// A ts is passed through untouched — it is an id, not a number to reformat.
|
|
38
|
+
const numeric = /^\d+(\.\d+)?$/.test(raw);
|
|
39
|
+
const seconds = numeric ? Number(raw) : Date.parse(raw) / 1000;
|
|
40
|
+
if (Number.isNaN(seconds))
|
|
41
41
|
throw new Error(`not a time: ${value}`);
|
|
42
|
-
|
|
42
|
+
// Milliseconds are the mistake worth naming: Slack takes the window without
|
|
43
|
+
// complaint, finds nothing that far in the future, and answers with an empty
|
|
44
|
+
// read that is indistinguishable from a channel where nobody spoke.
|
|
45
|
+
if (seconds > MAX_SECONDS) {
|
|
46
|
+
throw new Error(`${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
|
|
47
|
+
}
|
|
48
|
+
return numeric ? raw : String(seconds);
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Whether a session opened now is given the tool at all: the same two switches
|
|
@@ -91,6 +97,8 @@ export function slackToolSpec(execute, available) {
|
|
|
91
97
|
text: Type.Optional(Type.String()),
|
|
92
98
|
}),
|
|
93
99
|
available,
|
|
100
|
+
// Stands down with the tool: without Slack, its manual is a route to nowhere.
|
|
101
|
+
skill: "pier-slack",
|
|
94
102
|
execute,
|
|
95
103
|
};
|
|
96
104
|
}
|
|
@@ -165,10 +173,12 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
165
173
|
// own bounds are inclusive-ish, so the boundary message is dropped here
|
|
166
174
|
// rather than trusted to the API.
|
|
167
175
|
const after = toTs(input.after);
|
|
176
|
+
// Both reads take it, so it is read once: a cap that worked on a channel and
|
|
177
|
+
// was ignored on a thread would be the more expensive kind of surprise.
|
|
178
|
+
const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
|
|
168
179
|
if (input.operation === "read_channel") {
|
|
169
180
|
const since = after ?? toTs(input.since);
|
|
170
181
|
const until = toTs(input.until);
|
|
171
|
-
const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
|
|
172
182
|
return readChannel(deps, client, channel, since, until, after, limit);
|
|
173
183
|
}
|
|
174
184
|
if (input.operation === "read_thread") {
|
|
@@ -177,7 +187,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
177
187
|
: at?.threadTs;
|
|
178
188
|
if (!threadTs)
|
|
179
189
|
throw new Error("thread_ts is required outside a Slack thread");
|
|
180
|
-
return readThread(deps, client, channel, threadTs, after);
|
|
190
|
+
return readThread(deps, client, channel, threadTs, after, limit);
|
|
181
191
|
}
|
|
182
192
|
if (input.operation === "read_message") {
|
|
183
193
|
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
@@ -204,6 +214,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
204
214
|
at: sent.ts ? tsToIso(sent.ts) : null,
|
|
205
215
|
// Returned so a follow-up can reply under what was just posted.
|
|
206
216
|
threadTs: threadTs ?? sent.ts,
|
|
217
|
+
...inertMention(text),
|
|
207
218
|
};
|
|
208
219
|
}
|
|
209
220
|
if (input.operation === "edit") {
|
|
@@ -211,14 +222,23 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
211
222
|
// and Slack keeps no visible record of what it said before.
|
|
212
223
|
const ts = required(input.ts, "ts");
|
|
213
224
|
const text = messageText(input.text);
|
|
225
|
+
// Defaulted from `here` as `post` is: a correction is nearly always to a
|
|
226
|
+
// reply the agent made in this thread, and `conversations.history` cannot
|
|
227
|
+
// see inside a thread — without this the read below always comes up empty.
|
|
228
|
+
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
229
|
+
const inThread = asked || (channel === at?.channel ? at?.threadTs : undefined);
|
|
230
|
+
// Read before the write, because after it the old wording exists nowhere:
|
|
231
|
+
// Slack keeps no version, so this log is the only record of what was
|
|
232
|
+
// replaced. Best-effort — a failed read must not block the correction.
|
|
233
|
+
const was = await previousText(client, channel, ts, inThread);
|
|
214
234
|
try {
|
|
215
235
|
await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
|
|
216
236
|
}
|
|
217
237
|
catch (err) {
|
|
218
238
|
throw new Error(explain(err));
|
|
219
239
|
}
|
|
220
|
-
deps.log(`slack tool edited ${ts} in ${channel}`);
|
|
221
|
-
return { channel, ts, edited: true };
|
|
240
|
+
deps.log(`slack tool edited ${ts} in ${channel}; was: ${was ?? "(not captured)"}`);
|
|
241
|
+
return { channel, ts, edited: true, ...inertMention(text) };
|
|
222
242
|
}
|
|
223
243
|
if (input.operation === "delete") {
|
|
224
244
|
// Never defaulted from `here`: the thread's ts is the parent message, and
|
|
@@ -237,6 +257,50 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
237
257
|
}
|
|
238
258
|
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
239
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The one message at `ts`. A `ts` is unique only within its conversation, and
|
|
262
|
+
* `conversations.history` never returns what was posted inside a thread — so a
|
|
263
|
+
* reply has to be asked for through its thread, and only the caller knows.
|
|
264
|
+
*/
|
|
265
|
+
async function oneMessage(client, channel, ts, threadTs) {
|
|
266
|
+
const page = threadTs
|
|
267
|
+
? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
|
|
268
|
+
: await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
|
|
269
|
+
return page.messages.find((msg) => msg.ts === ts);
|
|
270
|
+
}
|
|
271
|
+
/** One line of what a message said, for the log an edit leaves behind. */
|
|
272
|
+
async function previousText(client, channel, ts, threadTs) {
|
|
273
|
+
try {
|
|
274
|
+
const text = (await oneMessage(client, channel, ts, threadTs))?.text;
|
|
275
|
+
return text === undefined ? undefined : text.slice(0, 200).replace(/\s+/g, " ");
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Not swallowed: the caller logs that the old wording was not captured,
|
|
279
|
+
// which is the fact that matters. Refusing the edit over it would be worse.
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* A plain `@alice` is the one Slack mistake that looks like it worked: the
|
|
285
|
+
* message goes up, renders as text, and notifies nobody. Reported after the
|
|
286
|
+
* fact rather than refused — a name in prose is legitimate, an unping is not
|
|
287
|
+
* worth losing the message over.
|
|
288
|
+
*/
|
|
289
|
+
function inertMention(text) {
|
|
290
|
+
const prose = text
|
|
291
|
+
.replace(/```[\s\S]*?```|`[^`]*`/g, "") // code says @ and # for other reasons
|
|
292
|
+
.replace(/<[^>]*>/g, ""); // already Slack syntax
|
|
293
|
+
const hit = /(?:^|\s)([@#][A-Za-z][\w.-]*)/.exec(prose)?.[1];
|
|
294
|
+
if (!hit)
|
|
295
|
+
return {};
|
|
296
|
+
const as = hit.startsWith("@")
|
|
297
|
+
? /^@(here|channel|everyone)$/.test(hit) ? `<!${hit.slice(1)}>` : "<@U…>"
|
|
298
|
+
: "<#C…>";
|
|
299
|
+
return {
|
|
300
|
+
hint: `${hit} is plain text and notified nobody — Slack needs ${as}. ` +
|
|
301
|
+
`Edit this ts if it was meant to reach someone.`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
240
304
|
/** Accept a `#name` or a bare name as well as an id — models prefer names. */
|
|
241
305
|
function resolveChannel(deps, given) {
|
|
242
306
|
if (/^[CDG][A-Z0-9]+$/.test(given))
|
|
@@ -265,30 +329,27 @@ async function readChannel(deps, client, channel, since, until, after, limit) {
|
|
|
265
329
|
messages: await lines(deps, client, window),
|
|
266
330
|
};
|
|
267
331
|
}
|
|
268
|
-
async function readThread(deps, client, channel, threadTs, after) {
|
|
332
|
+
async function readThread(deps, client, channel, threadTs, after, limit) {
|
|
269
333
|
const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
|
|
270
|
-
const
|
|
334
|
+
const all = newerThan(transcript(fetched.messages), after);
|
|
335
|
+
// Oldest first, as in a channel read: a thread cut at its newest end still
|
|
336
|
+
// reads as a thread, and the cut has to be said either way — a long thread
|
|
337
|
+
// that answers as if it were complete is the read nobody double-checks.
|
|
338
|
+
const messages = all.slice(0, limit);
|
|
271
339
|
return {
|
|
272
340
|
channel,
|
|
273
341
|
// Hoisted: every line in a thread carries the same one.
|
|
274
342
|
threadTs,
|
|
275
343
|
count: messages.length,
|
|
344
|
+
...(fetched.truncated || all.length > messages.length ? { truncated: true } : {}),
|
|
276
345
|
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
277
346
|
format: LINE_FORMAT,
|
|
278
347
|
messages: await lines(deps, client, messages),
|
|
279
348
|
};
|
|
280
349
|
}
|
|
281
|
-
/**
|
|
282
|
-
* One message, because that is sometimes the whole question. A `ts` is unique
|
|
283
|
-
* only within its conversation, and `conversations.history` never returns what
|
|
284
|
-
* was posted inside a thread — so a reply has to be asked for through its
|
|
285
|
-
* thread, and saying which one is the caller's job.
|
|
286
|
-
*/
|
|
350
|
+
/** One message, because that is sometimes the whole question. */
|
|
287
351
|
async function readMessage(deps, client, channel, ts, threadTs) {
|
|
288
|
-
const
|
|
289
|
-
? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
|
|
290
|
-
: await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
|
|
291
|
-
const found = page.messages.find((msg) => msg.ts === ts);
|
|
352
|
+
const found = await oneMessage(client, channel, ts, threadTs);
|
|
292
353
|
if (!found) {
|
|
293
354
|
throw new Error(threadTs
|
|
294
355
|
? `no message ${ts} in thread ${threadTs}`
|
|
@@ -342,7 +403,7 @@ function explain(err) {
|
|
|
342
403
|
const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
|
|
343
404
|
return {
|
|
344
405
|
channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
|
|
345
|
-
not_in_channel: "Pier's bot is not in that channel; someone has to invite
|
|
406
|
+
not_in_channel: "Pier's bot is not in that channel; someone has to run `/invite @Pier` there before it can read",
|
|
346
407
|
missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
|
|
347
408
|
ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
|
|
348
409
|
thread_not_found: "no thread with that ts in this channel",
|
package/dist/channels/slack.js
CHANGED
|
@@ -52,8 +52,6 @@ const DRAIN_TIMEOUT_MS = 5000;
|
|
|
52
52
|
*/
|
|
53
53
|
const DEDUP_TTL_MS = 5 * 60_000;
|
|
54
54
|
const DEDUP_MAX = 2000;
|
|
55
|
-
/** How often the straggler sweep may run; see onEnvelope. */
|
|
56
|
-
const SWEEP_EVERY_MS = 60_000;
|
|
57
55
|
/**
|
|
58
56
|
* Commands that may appear as a bare word, and exactly how many arguments each
|
|
59
57
|
* takes. Both halves are load-bearing, because Slack gives us no leading `/` to
|
|
@@ -132,7 +130,6 @@ export class SlackChannel {
|
|
|
132
130
|
out;
|
|
133
131
|
socket;
|
|
134
132
|
running = false;
|
|
135
|
-
sweptAt = 0;
|
|
136
133
|
constructor(deps) {
|
|
137
134
|
this.deps = deps;
|
|
138
135
|
const config = deps.store.get("slack");
|
|
@@ -195,13 +192,8 @@ export class SlackChannel {
|
|
|
195
192
|
onEnvelope(env, onMessage) {
|
|
196
193
|
if (!this.running)
|
|
197
194
|
return;
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
// hundreds of times a minute for something that changes every 30.
|
|
201
|
-
if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
|
|
202
|
-
this.sweptAt = Date.now();
|
|
203
|
-
void this.receipts.sweep();
|
|
204
|
-
}
|
|
195
|
+
// Asked on every envelope, throttled inside receipts.ts.
|
|
196
|
+
void this.receipts.sweep();
|
|
205
197
|
if (env.type === "events_api") {
|
|
206
198
|
const payload = env.payload;
|
|
207
199
|
const event = payload?.event;
|
|
@@ -124,6 +124,7 @@ export class TelegramChannel {
|
|
|
124
124
|
const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
|
|
125
125
|
if (!this.running)
|
|
126
126
|
return;
|
|
127
|
+
// Asked on every round trip, throttled inside receipts.ts.
|
|
127
128
|
void this.receipts.sweep();
|
|
128
129
|
if (!updates.length && Date.now() - startedAt < 1000) {
|
|
129
130
|
await new Promise((r) => setTimeout(r, 1000));
|
package/dist/core/hub.js
CHANGED
|
@@ -36,9 +36,18 @@ export class EventHub {
|
|
|
36
36
|
sessionId,
|
|
37
37
|
...payload,
|
|
38
38
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// Text deltas fan out live but never enter the ring: one long reply emits
|
|
40
|
+
// thousands of them, so a ring that held them would hold *only* them and
|
|
41
|
+
// would have evicted the turn-start, tool and turn-end events a
|
|
42
|
+
// reconnecting client replays for. The text is not lost — `turn-end`
|
|
43
|
+
// carries the full reply (web/ui/chat.ts treats it as authoritative).
|
|
44
|
+
// Thinking stays replayable because a native EventSource reconnect does
|
|
45
|
+
// not reload the transcript snapshot that would otherwise restore it.
|
|
46
|
+
if (payload.type !== "text-delta") {
|
|
47
|
+
b.buffer.push(event);
|
|
48
|
+
if (b.buffer.length > RING_SIZE)
|
|
49
|
+
b.buffer.shift();
|
|
50
|
+
}
|
|
42
51
|
fanOut(b.subscribers, event);
|
|
43
52
|
}
|
|
44
53
|
subscribe(sessionId, fn) {
|
|
@@ -46,7 +55,8 @@ export class EventHub {
|
|
|
46
55
|
b.subscribers.add(fn);
|
|
47
56
|
return () => b.subscribers.delete(fn);
|
|
48
57
|
}
|
|
49
|
-
/** Events with seq > afterSeq still held in the ring buffer
|
|
58
|
+
/** Events with seq > afterSeq still held in the ring buffer — oldest first,
|
|
59
|
+
* and without text deltas, which are live-only. */
|
|
50
60
|
replay(sessionId, afterSeq) {
|
|
51
61
|
return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
|
|
52
62
|
}
|
package/dist/core/inbox.js
CHANGED
|
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
|
|
|
11
11
|
import { basename, join } from "node:path";
|
|
12
12
|
import { pierPath } from "../paths.js";
|
|
13
13
|
import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
|
|
14
|
-
/** Where every inbound file lives;
|
|
14
|
+
/** Where every inbound file lives; the attachment route allowlists this root. */
|
|
15
15
|
export const INBOX_DIR = pierPath("inbox");
|
|
16
16
|
/**
|
|
17
17
|
* Write one inbound file and return its absolute path. The timestamp-random
|