@timqi/pier 0.0.1 → 0.0.2
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 +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Everything policy-shaped (mention/bind gates, per-chat overrides) is in
|
|
13
13
|
// config.ts, platform-blind and shared with the adapters still to come.
|
|
14
|
-
import { formatTurnMeta, originLabel } from "../core/reply.js";
|
|
14
|
+
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
15
|
+
import { saveInbound } from "../core/inbox.js";
|
|
16
|
+
import { fileMarker, lostMarker, MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
15
17
|
import { logger } from "../log.js";
|
|
16
18
|
import { Chains } from "./chains.js";
|
|
17
19
|
import { parseCommand } from "./commands.js";
|
|
@@ -158,14 +160,14 @@ export class TelegramChannel {
|
|
|
158
160
|
if (!msg.from || msg.from.id === this.me?.id)
|
|
159
161
|
return; // own echo, or malformed
|
|
160
162
|
const raw = (msg.text ?? msg.caption ?? "").trim();
|
|
161
|
-
if (!raw && !msg.photo?.length)
|
|
163
|
+
if (!raw && !msg.photo?.length && !msg.document)
|
|
162
164
|
return;
|
|
163
165
|
const chatId = String(msg.chat.id);
|
|
164
166
|
const isDm = msg.chat.type === "private";
|
|
165
167
|
const kind = isDm ? "dm" : msg.chat.is_forum ? "forum" : "group";
|
|
166
168
|
const name = msg.chat.title ?? [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
|
|
167
169
|
this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
|
|
168
|
-
const text = this.stripMention(raw
|
|
170
|
+
const text = this.stripMention(raw);
|
|
169
171
|
const command = parseCommand(text);
|
|
170
172
|
// A command aimed at another bot in the same group is not ours to answer.
|
|
171
173
|
const mine = !command?.target || command.target.toLowerCase() === this.me?.username.toLowerCase();
|
|
@@ -194,12 +196,12 @@ export class TelegramChannel {
|
|
|
194
196
|
return;
|
|
195
197
|
// `@bot` on its own (text is empty once the mention is stripped) and
|
|
196
198
|
// `/settings` are the same request: show me this conversation's settings.
|
|
197
|
-
if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length))) {
|
|
199
|
+
if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length && !msg.document))) {
|
|
198
200
|
return this.panel.open(here, chatId, msg.message_thread_id);
|
|
199
201
|
}
|
|
200
202
|
// Downloading only past the gate: an unauthorized sender must not be able
|
|
201
203
|
// to make the bot pull bytes on their behalf.
|
|
202
|
-
const
|
|
204
|
+
const markers = await this.saveAttachments(msg);
|
|
203
205
|
const topicId = await this.routeTopic(msg, text);
|
|
204
206
|
const key = { channelId: this.id, conversationId: conversationId(chatId, topicId) };
|
|
205
207
|
this.receipts.mark(key.conversationId, chatId, String(msg.message_id));
|
|
@@ -211,8 +213,7 @@ export class TelegramChannel {
|
|
|
211
213
|
// A group is many people talking into one session; the update already
|
|
212
214
|
// carries the name, so no lookup is needed here.
|
|
213
215
|
sender: { id: String(msg.from.id), name: senderName(msg.from) },
|
|
214
|
-
text,
|
|
215
|
-
images,
|
|
216
|
+
text: [text, ...markers].filter(Boolean).join("\n"),
|
|
216
217
|
mode: "steer",
|
|
217
218
|
});
|
|
218
219
|
}
|
|
@@ -354,17 +355,32 @@ export class TelegramChannel {
|
|
|
354
355
|
return msg.message_thread_id;
|
|
355
356
|
}
|
|
356
357
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
358
|
+
/** Save the message's attachments to the inbox; each becomes a prompt
|
|
359
|
+
* line — a failed one becomes a lost-marker line, never silence (5b). */
|
|
360
|
+
async saveAttachments(msg) {
|
|
361
|
+
// Telegram sends a photo as a size ladder — the last entry is the largest.
|
|
362
|
+
const photo = msg.photo?.at(-1);
|
|
363
|
+
const doc = msg.document;
|
|
364
|
+
const wanted = [
|
|
365
|
+
...(photo ? [{ id: photo.file_id, name: undefined, label: "photo", mime: "image/jpeg", size: photo.file_size }] : []),
|
|
366
|
+
...(doc ? [{ id: doc.file_id, name: doc.file_name, label: doc.file_name ?? "file", mime: doc.mime_type ?? "application/octet-stream", size: doc.file_size }] : []),
|
|
367
|
+
];
|
|
368
|
+
const markers = [];
|
|
369
|
+
for (const { id, name, label, mime, size } of wanted) {
|
|
370
|
+
if (size !== undefined && size > MAX_INBOUND_BYTES) {
|
|
371
|
+
markers.push(lostMarker(label, "too large"));
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const file = await this.api.downloadFile(id);
|
|
376
|
+
markers.push(fileMarker(await saveInbound(this.id, name ?? file.name, mime, file.bytes)));
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
this.log(`attachment download failed: ${String(err)}`);
|
|
380
|
+
markers.push(lostMarker(label, "download failed"));
|
|
381
|
+
}
|
|
367
382
|
}
|
|
383
|
+
return markers;
|
|
368
384
|
}
|
|
369
385
|
// --- addressing ------------------------------------------------------------
|
|
370
386
|
/** Mentioned, replying to the bot, or a slash command aimed at this bot. */
|
|
@@ -379,14 +395,10 @@ export class TelegramChannel {
|
|
|
379
395
|
return !!this.me?.username && text.toLowerCase().includes(handle);
|
|
380
396
|
}
|
|
381
397
|
/** A leading @bot is addressing, not content — the agent should not see it. */
|
|
382
|
-
stripMention(text
|
|
398
|
+
stripMention(text) {
|
|
383
399
|
const handle = `@${this.me?.username ?? ""}`;
|
|
384
400
|
if (!this.me?.username)
|
|
385
401
|
return text;
|
|
386
|
-
const mention = msg.entities?.find((e) => e.type === "mention" && e.offset === 0);
|
|
387
|
-
if (mention && text.slice(0, mention.length).toLowerCase() === handle.toLowerCase()) {
|
|
388
|
-
return text.slice(mention.length).replace(/^[\s,:-]+/, "");
|
|
389
|
-
}
|
|
390
402
|
return text.toLowerCase().startsWith(handle.toLowerCase())
|
|
391
403
|
? text.slice(handle.length).replace(/^[\s,:-]+/, "")
|
|
392
404
|
: text;
|
|
@@ -404,12 +416,9 @@ export class TelegramChannel {
|
|
|
404
416
|
// the person waiting cannot tell. See AGENTS.md — an empty turn is still an
|
|
405
417
|
// event, and an event nobody can see is not observable.
|
|
406
418
|
const buttons = keyboard(reply.suggestions);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
: reply.silence
|
|
411
|
-
? `<i>stayed silent \u2014 ${escapeHtml(reply.silence)}</i>`
|
|
412
|
-
: "<i>no reply</i>";
|
|
419
|
+
const quiet = isSilentReply(reply)
|
|
420
|
+
? `<i>${quietLabel(reply.silence && escapeHtml(reply.silence))}</i>`
|
|
421
|
+
: "";
|
|
413
422
|
const body = (text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta);
|
|
414
423
|
try {
|
|
415
424
|
if (body.trim()) {
|
package/dist/cli.js
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// server until a command that needs it: `pier service install` on a machine
|
|
4
4
|
// with no database should not open one.
|
|
5
5
|
//
|
|
6
|
-
// Hand-rolled against node:util's parseArgs rather than a CLI framework:
|
|
7
|
-
//
|
|
6
|
+
// Hand-rolled against node:util's parseArgs rather than a CLI framework: this
|
|
7
|
+
// small command set does not earn a dependency (AGENTS.md 8).
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
|
-
import {
|
|
9
|
+
import { accessSync, constants, realpathSync } from "node:fs";
|
|
10
|
+
import { delimiter, join, resolve } from "node:path";
|
|
10
11
|
import { parseArgs } from "node:util";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
|
|
13
|
+
import { currentVersion, UpdateCheck } from "./update.js";
|
|
14
|
+
const version = currentVersion();
|
|
13
15
|
const HELP = `pier ${version} — a self-hosted workspace for coding agents
|
|
14
16
|
|
|
15
17
|
Usage
|
|
@@ -17,6 +19,11 @@ Usage
|
|
|
17
19
|
pier service install write and start a systemd user unit (Linux)
|
|
18
20
|
pier service uninstall stop it and remove the unit
|
|
19
21
|
pier service status what systemd thinks of it
|
|
22
|
+
pier update install the latest release and restart the service
|
|
23
|
+
pier update --check only say whether one exists
|
|
24
|
+
pier restart finish running turns first, then restart the service
|
|
25
|
+
pier reload re-read channel config and recycle idle sessions
|
|
26
|
+
pier backup snapshot pier.db before a manual update
|
|
20
27
|
pier --version | --help
|
|
21
28
|
|
|
22
29
|
Options for "service install"
|
|
@@ -28,19 +35,40 @@ Options for "service install"
|
|
|
28
35
|
The workbench is behind a password generated on first run and printed once.
|
|
29
36
|
Under systemd that print lands in the journal: journalctl --user -u pier -e
|
|
30
37
|
`;
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
38
|
+
const say = (message) => void process.stdout.write(`${message}\n`);
|
|
39
|
+
const fail = (message) => {
|
|
40
|
+
process.stderr.write(`pier: ${message}\n`);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
};
|
|
43
|
+
const parsed = (() => {
|
|
44
|
+
try {
|
|
45
|
+
return parseArgs({
|
|
46
|
+
allowPositionals: true,
|
|
47
|
+
strict: true,
|
|
48
|
+
options: {
|
|
49
|
+
help: { type: "boolean", short: "h" },
|
|
50
|
+
version: { type: "boolean", short: "v" },
|
|
51
|
+
check: { type: "boolean" },
|
|
52
|
+
force: { type: "boolean" },
|
|
53
|
+
port: { type: "string" },
|
|
54
|
+
host: { type: "string" },
|
|
55
|
+
"pier-home": { type: "string" },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
const { values, positionals } = parsed;
|
|
64
|
+
const [command, subcommand, ...extra] = positionals;
|
|
65
|
+
if (extra.length)
|
|
66
|
+
fail(`unexpected argument "${extra[0]}"`);
|
|
67
|
+
const allowOnly = (allowed, usage) => {
|
|
68
|
+
const invalid = Object.keys(values).find((key) => !allowed.includes(key));
|
|
69
|
+
if (invalid)
|
|
70
|
+
fail(`--${invalid} is not valid for ${usage}`);
|
|
71
|
+
};
|
|
44
72
|
if (values.help || command === "help") {
|
|
45
73
|
process.stdout.write(HELP);
|
|
46
74
|
}
|
|
@@ -48,49 +76,169 @@ else if (values.version || command === "version") {
|
|
|
48
76
|
process.stdout.write(`${version}\n`);
|
|
49
77
|
}
|
|
50
78
|
else if (!command) {
|
|
79
|
+
allowOnly([], "pier");
|
|
51
80
|
// The server starts on import; this file stays a dispatcher.
|
|
52
81
|
await import("./main.js");
|
|
53
82
|
}
|
|
54
83
|
else if (command === "service") {
|
|
55
84
|
await service(subcommand);
|
|
56
85
|
}
|
|
86
|
+
else if (command === "backup") {
|
|
87
|
+
if (subcommand)
|
|
88
|
+
fail(`unexpected argument "${subcommand}"`);
|
|
89
|
+
allowOnly([], "pier backup");
|
|
90
|
+
await backup();
|
|
91
|
+
}
|
|
92
|
+
else if (command === "update") {
|
|
93
|
+
if (subcommand)
|
|
94
|
+
fail(`unexpected argument "${subcommand}"`);
|
|
95
|
+
allowOnly(["check"], "pier update");
|
|
96
|
+
await update(values.check === true);
|
|
97
|
+
}
|
|
98
|
+
else if (command === "restart" || command === "reload") {
|
|
99
|
+
if (subcommand)
|
|
100
|
+
fail(`unexpected argument "${subcommand}"`);
|
|
101
|
+
allowOnly([], `pier ${command}`);
|
|
102
|
+
await signalService(command);
|
|
103
|
+
}
|
|
57
104
|
else {
|
|
58
105
|
process.stderr.write(`pier: unknown command "${command}"\n\n${HELP}`);
|
|
59
106
|
process.exit(2);
|
|
60
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Checking is Pier's own code; applying it is npm's. Under systemd the work is
|
|
110
|
+
* handed to a second unit — this process is about to be restarted, and a child
|
|
111
|
+
* of the service being restarted dies with it.
|
|
112
|
+
*/
|
|
113
|
+
async function update(checkOnly) {
|
|
114
|
+
const check = new UpdateCheck(version);
|
|
115
|
+
await check.refresh();
|
|
116
|
+
const { current, latest, available } = check.status();
|
|
117
|
+
if (latest === null) {
|
|
118
|
+
process.stderr.write(`could not reach the registry — running ${current}.\n`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!available) {
|
|
123
|
+
process.stdout.write(`${current} is the latest.\n`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
process.stdout.write(`${latest} is out (running ${current}).\n`);
|
|
127
|
+
if (checkOnly)
|
|
128
|
+
return;
|
|
129
|
+
const { startUpdate } = await import("./service.js");
|
|
130
|
+
if (process.platform === "linux") {
|
|
131
|
+
const started = startUpdate({ say });
|
|
132
|
+
if (started === "started")
|
|
133
|
+
return;
|
|
134
|
+
if (started === "failed") {
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// No service manager owns this process, so do not mutate its rollback point
|
|
140
|
+
// until the operator is ready to run all three steps.
|
|
141
|
+
say(`pier backup`);
|
|
142
|
+
say(`npm install -g @timqi/pier@${latest}`);
|
|
143
|
+
say(`then restart Pier.`);
|
|
144
|
+
}
|
|
145
|
+
/** Both are signals to the running unit: SIGUSR2 drains then exits (systemd
|
|
146
|
+
* starts the next process), SIGHUP reloads config in place (main.ts). */
|
|
147
|
+
async function signalService(command) {
|
|
148
|
+
if (process.platform !== "linux") {
|
|
149
|
+
return fail(`only under the systemd service — send ${command === "restart" ? "SIGUSR2" : "SIGHUP"} to the pier process yourself`);
|
|
150
|
+
}
|
|
151
|
+
const { UNIT_NAME } = await import("./service.js");
|
|
152
|
+
const signal = command === "restart" ? "SIGUSR2" : "SIGHUP";
|
|
153
|
+
try {
|
|
154
|
+
// `--kill-who`, not the newer `--kill-whom`: the old spelling is the one
|
|
155
|
+
// every systemd still parses (systemd/systemd#29793).
|
|
156
|
+
execFileSync("systemctl", ["--user", "kill", "-s", signal, "--kill-who=main", UNIT_NAME], { stdio: "inherit" });
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
// A failed kill already printed why on the inherited stderr; a missing
|
|
160
|
+
// systemctl printed nothing, so name it (same shape as `service status`).
|
|
161
|
+
if (err.code === "ENOENT") {
|
|
162
|
+
process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
|
|
163
|
+
}
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
say(command === "restart"
|
|
168
|
+
? "draining — running turns finish first (up to 5 minutes), then Pier restarts."
|
|
169
|
+
: "reloading — adapters re-read their config; idle sessions re-open with the current one.");
|
|
170
|
+
}
|
|
171
|
+
async function backup() {
|
|
172
|
+
const [{ backupDb }, { PIER_DB }] = await Promise.all([import("./db.js"), import("./paths.js")]);
|
|
173
|
+
const path = backupDb(PIER_DB);
|
|
174
|
+
process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
|
|
175
|
+
}
|
|
176
|
+
function commandPath(name) {
|
|
177
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
178
|
+
const path = join(dir || ".", name);
|
|
179
|
+
try {
|
|
180
|
+
accessSync(path, constants.X_OK);
|
|
181
|
+
return realpathSync(path);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Keep looking: version managers commonly put several prefixes on PATH.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return fail(`${name} is not executable on PATH`);
|
|
188
|
+
}
|
|
61
189
|
async function service(action = "status") {
|
|
62
190
|
const { install, uninstall, UNIT_NAME } = await import("./service.js");
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
191
|
+
const systemdAction = action === "install" || action === "uninstall";
|
|
192
|
+
if (systemdAction && process.platform !== "linux") {
|
|
65
193
|
process.stderr.write(`pier service is systemd, so Linux only — this is ${process.platform}.\n` +
|
|
66
194
|
`Run "pier" in a terminal, or under whatever supervisor you already use;\n` +
|
|
67
195
|
`it needs no arguments and keeps its state in $PIER_HOME (~/.pier).\n`);
|
|
68
196
|
process.exit(2);
|
|
69
197
|
}
|
|
70
198
|
switch (action) {
|
|
71
|
-
case "install":
|
|
72
|
-
install
|
|
73
|
-
|
|
74
|
-
|
|
199
|
+
case "install": { // The installed unit owns this Node prefix until replaced.
|
|
200
|
+
allowOnly(["force", "port", "host", "pier-home"], "pier service install");
|
|
201
|
+
const port = typeof values.port === "string" ? Number(values.port) : 3141;
|
|
202
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
203
|
+
fail("--port must be an integer from 1 to 65535");
|
|
204
|
+
const host = typeof values.host === "string" ? values.host : "127.0.0.1";
|
|
205
|
+
if (!host || /\s|[\0\r\n]/.test(host))
|
|
206
|
+
fail("--host must be one address with no whitespace");
|
|
207
|
+
const rawHome = values["pier-home"];
|
|
208
|
+
if (rawHome === "")
|
|
209
|
+
fail("--pier-home must not be empty");
|
|
210
|
+
const pierHome = typeof rawHome === "string" ? resolve(rawHome) : undefined;
|
|
211
|
+
if (!install({
|
|
75
212
|
execPath: process.execPath,
|
|
213
|
+
npmPath: commandPath("npm"),
|
|
76
214
|
entry: fileURLToPath(new URL("./main.js", import.meta.url)),
|
|
77
|
-
host
|
|
78
|
-
port
|
|
79
|
-
pierHome
|
|
215
|
+
host,
|
|
216
|
+
port,
|
|
217
|
+
pierHome,
|
|
80
218
|
force: values.force === true,
|
|
81
219
|
say,
|
|
82
|
-
})
|
|
220
|
+
}))
|
|
221
|
+
process.exitCode = 1;
|
|
83
222
|
return;
|
|
223
|
+
}
|
|
84
224
|
case "uninstall":
|
|
85
|
-
|
|
225
|
+
allowOnly([], "pier service uninstall");
|
|
226
|
+
if (!uninstall(undefined, say))
|
|
227
|
+
process.exitCode = 1;
|
|
86
228
|
return;
|
|
87
229
|
case "status":
|
|
230
|
+
allowOnly([], "pier service status");
|
|
88
231
|
try {
|
|
89
232
|
// Inherited, not captured: systemctl's own output is the answer, and
|
|
90
233
|
// its exit code is nonzero for a service that is merely stopped.
|
|
91
234
|
execFileSync("systemctl", ["--user", "status", UNIT_NAME], { stdio: "inherit" });
|
|
92
235
|
}
|
|
93
|
-
catch {
|
|
236
|
+
catch (err) {
|
|
237
|
+
// A stopped service already printed its status; a missing systemctl
|
|
238
|
+
// printed nothing, so name it.
|
|
239
|
+
if (err.code === "ENOENT") {
|
|
240
|
+
process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
|
|
241
|
+
}
|
|
94
242
|
process.exitCode = 1;
|
|
95
243
|
}
|
|
96
244
|
return;
|
package/dist/core/hub.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
// Per-session event fan-out with a replay ring buffer. The single stamping
|
|
2
2
|
// point for seq/ts — nothing else in the system numbers events.
|
|
3
|
+
import { logger } from "../log.js";
|
|
4
|
+
const log = logger("core");
|
|
3
5
|
const RING_SIZE = 1000;
|
|
6
|
+
/** A throwing subscriber costs only itself — emit() runs on the emitter's
|
|
7
|
+
* stack (Pi's dispatch path, for session events), which must not unwind. */
|
|
8
|
+
function fanOut(subscribers, event) {
|
|
9
|
+
for (const fn of subscribers) {
|
|
10
|
+
try {
|
|
11
|
+
fn(event);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
log.warn(`event subscriber threw: ${String(err)}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
4
18
|
export class EventHub {
|
|
5
19
|
buses = new Map();
|
|
6
20
|
// Workspace bus: no seq, no replay — a client that missed events just
|
|
@@ -25,9 +39,7 @@ export class EventHub {
|
|
|
25
39
|
b.buffer.push(event);
|
|
26
40
|
if (b.buffer.length > RING_SIZE)
|
|
27
41
|
b.buffer.shift();
|
|
28
|
-
|
|
29
|
-
fn(event);
|
|
30
|
-
return event;
|
|
42
|
+
fanOut(b.subscribers, event);
|
|
31
43
|
}
|
|
32
44
|
subscribe(sessionId, fn) {
|
|
33
45
|
const b = this.bus(sessionId);
|
|
@@ -39,8 +51,7 @@ export class EventHub {
|
|
|
39
51
|
return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
|
|
40
52
|
}
|
|
41
53
|
emitWorkspace(event) {
|
|
42
|
-
|
|
43
|
-
fn(event);
|
|
54
|
+
fanOut(this.workspace, event);
|
|
44
55
|
}
|
|
45
56
|
subscribeWorkspace(fn) {
|
|
46
57
|
this.workspace.add(fn);
|
|
@@ -50,4 +61,24 @@ export class EventHub {
|
|
|
50
61
|
lastSeq(sessionId) {
|
|
51
62
|
return this.buses.get(sessionId)?.seq ?? 0;
|
|
52
63
|
}
|
|
64
|
+
/** Whether anyone is still watching this session (an open SSE stream). */
|
|
65
|
+
hasSubscribers(sessionId) {
|
|
66
|
+
return (this.buses.get(sessionId)?.subscribers.size ?? 0) > 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Release the ring of a session nobody is watching — the memory an evicted
|
|
70
|
+
* session leaves behind (1000 events of text, per session, forever).
|
|
71
|
+
*
|
|
72
|
+
* The bus itself stays, holding its seq: a client that reconnects with a
|
|
73
|
+
* Last-Event-ID drops anything numbered at or below what it saw, so a
|
|
74
|
+
* counter restarting at 1 would make every later event invisible to it.
|
|
75
|
+
* What is left is a number and an empty set.
|
|
76
|
+
*/
|
|
77
|
+
dropReplay(sessionId) {
|
|
78
|
+
if (this.hasSubscribers(sessionId))
|
|
79
|
+
return;
|
|
80
|
+
const b = this.buses.get(sessionId);
|
|
81
|
+
if (b)
|
|
82
|
+
b.buffer = [];
|
|
83
|
+
}
|
|
53
84
|
}
|
package/dist/core/identity.js
CHANGED
|
@@ -61,6 +61,11 @@ export class SenderPrefix {
|
|
|
61
61
|
const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
|
|
62
62
|
return `[${[who, when].filter(Boolean).join(" ")}]`;
|
|
63
63
|
}
|
|
64
|
+
/** Forget a session being evicted. Costs one redundant header if it comes
|
|
65
|
+
* back, which is the same price a restart already pays. */
|
|
66
|
+
forget(sessionId) {
|
|
67
|
+
this.seen.delete(sessionId);
|
|
68
|
+
}
|
|
64
69
|
}
|
|
65
70
|
/** Put the prefix above the message, or hand the message back untouched. */
|
|
66
71
|
export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The inbound-file convention, browser-safe: what a user's attachment may be
|
|
2
|
+
// called, the marker line that tells the agent about it, the parser that
|
|
3
|
+
// splits it back out of a message, and the size cap both ends enforce.
|
|
4
|
+
// Producers are node code (channels/, web/server.ts) but the web composer
|
|
5
|
+
// builds markers and the web chat parses them in the browser, so the grammar
|
|
6
|
+
// lives in a module with no node imports that either side can load. The
|
|
7
|
+
// filesystem half (saving the bytes) is core/inbox.ts.
|
|
8
|
+
/** One cap for every inbound path: composer, upload route, Slack metadata. */
|
|
9
|
+
export const MAX_INBOUND_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
/** Extension for a name-less upload (a pasted screenshot has no filename). */
|
|
11
|
+
const MIME_EXT = {
|
|
12
|
+
"image/png": ".png",
|
|
13
|
+
"image/jpeg": ".jpg",
|
|
14
|
+
"image/gif": ".gif",
|
|
15
|
+
"image/webp": ".webp",
|
|
16
|
+
"application/pdf": ".pdf",
|
|
17
|
+
"text/plain": ".txt",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* A filename that is safe as a path segment and inside a markdown link:
|
|
21
|
+
* basename only (no traversal), whitespace and link-breaking characters
|
|
22
|
+
* folded to `-`, length capped.
|
|
23
|
+
*/
|
|
24
|
+
export function safeName(name, mimeType) {
|
|
25
|
+
const base = (name ?? "").split("/").pop().replace(/[\s\\()[\]<>%#?]/g, "-");
|
|
26
|
+
if (!base || base === "." || base === "..")
|
|
27
|
+
return `file${MIME_EXT[mimeType] ?? ""}`;
|
|
28
|
+
if (base.length <= 64)
|
|
29
|
+
return base;
|
|
30
|
+
const dot = base.lastIndexOf(".");
|
|
31
|
+
const ext = dot > 0 ? base.slice(dot, dot + 16) : "";
|
|
32
|
+
return base.slice(0, 64 - ext.length) + ext;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The prompt line for a saved file — the attachment convention, inbound. The
|
|
36
|
+
* path is percent-encoded (parentheses included, which encodeURI leaves
|
|
37
|
+
* alone) so the link survives markdown and the marker regex even when
|
|
38
|
+
* `PIER_HOME` contains spaces or parens; splitInboundFiles decodes.
|
|
39
|
+
*/
|
|
40
|
+
export const fileMarker = (path) => `[${path.split("/").pop() ?? "file"}](file://${encodeURI(path).replace(/\(/g, "%28").replace(/\)/g, "%29")})`;
|
|
41
|
+
/**
|
|
42
|
+
* The conversation-visible line for an attachment that never made it (5b: a
|
|
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.
|
|
45
|
+
*/
|
|
46
|
+
export const lostMarker = (name, reason) => `[attachment lost: ${name} — ${reason}]`;
|
|
47
|
+
// A whole line that is one `[name](file:///…)` link — what fileMarker emits.
|
|
48
|
+
const MARKER_RE = /^\[[^\]\n]*\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)$/;
|
|
49
|
+
/**
|
|
50
|
+
* Split a user message into its typed text and the attached files' paths.
|
|
51
|
+
* Only the contiguous *trailing* block of marker lines is an attachment —
|
|
52
|
+
* that is where every producer puts them — so a `file://` link the user
|
|
53
|
+
* wrote mid-message stays message text.
|
|
54
|
+
*/
|
|
55
|
+
export function splitInboundFiles(raw) {
|
|
56
|
+
const lines = raw.split("\n");
|
|
57
|
+
let start = lines.length;
|
|
58
|
+
while (start > 0 && MARKER_RE.test(lines[start - 1].trim()))
|
|
59
|
+
start--;
|
|
60
|
+
const paths = lines.slice(start).map((line) => {
|
|
61
|
+
const path = MARKER_RE.exec(line.trim())[1];
|
|
62
|
+
try {
|
|
63
|
+
return decodeURIComponent(path);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return path; // not percent-encoded — take the path as written
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return { text: lines.slice(0, start).join("\n").trimEnd(), paths };
|
|
70
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Inbound user files: bytes land on disk exactly once.
|
|
2
|
+
//
|
|
3
|
+
// A photo pasted on the web, dropped in Telegram or uploaded to Slack used to
|
|
4
|
+
// travel as base64 through the seam into the transcript, where it was re-sent
|
|
5
|
+
// with every provider request until compaction. Now the adapter (or the web
|
|
6
|
+
// upload route) saves the bytes under `$PIER_HOME/inbox/<channel>/` and the
|
|
7
|
+
// prompt carries only a marker line (core/inbound-file.ts owns that grammar),
|
|
8
|
+
// so the agent reads a file only when it decides the file is worth looking at.
|
|
9
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
import { pierPath } from "../paths.js";
|
|
13
|
+
import { safeName } from "./inbound-file.js";
|
|
14
|
+
/** Where every inbound file lives; web/files.ts allowlists this root. */
|
|
15
|
+
export const INBOX_DIR = pierPath("inbox");
|
|
16
|
+
/**
|
|
17
|
+
* Write one inbound file and return its absolute path. The timestamp-random
|
|
18
|
+
* prefix keeps concurrent saves collision-free (`wx` turns the impossible
|
|
19
|
+
* collision into an error instead of an overwrite) and makes `ls` read as a
|
|
20
|
+
* timeline. Owner-only modes: uploads are private conversation content on a
|
|
21
|
+
* possibly shared machine. Nothing is ever deleted here — pruning the inbox
|
|
22
|
+
* is the operator's call (docs/deploy.md).
|
|
23
|
+
*/
|
|
24
|
+
export async function saveInbound(channelId, name, mimeType, bytes) {
|
|
25
|
+
// The channel id is ours ("web" | "telegram" | "slack"), not user input,
|
|
26
|
+
// but basename() keeps a future id honest.
|
|
27
|
+
const dir = join(INBOX_DIR, basename(channelId));
|
|
28
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
const path = join(dir, `${String(Date.now())}-${randomBytes(3).toString("hex")}-${safeName(name, mimeType)}`);
|
|
30
|
+
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
31
|
+
return path;
|
|
32
|
+
}
|
package/dist/core/queue.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// The whole queue policy. Fixed by docs/architecture.md — do not add options.
|
|
2
2
|
export function decide(msg, state) {
|
|
3
|
+
// An explicit mode takes the text verbatim: IM sends steer for every
|
|
4
|
+
// message, so a leading "!" there is content, not a control prefix —
|
|
5
|
+
// consuming it silently rewrote what the person typed.
|
|
6
|
+
if (msg.mode === "steer" || msg.mode === "followUp") {
|
|
7
|
+
return { action: state === "idle" ? "prompt" : msg.mode, text: msg.text };
|
|
8
|
+
}
|
|
9
|
+
// On auto the "!" is a control prefix and always consumed — whether the
|
|
10
|
+
// turn happened to end first must not decide if it was content. Idle just
|
|
11
|
+
// means there is nothing to steer, so it degenerates to a prompt.
|
|
3
12
|
const steerPrefixed = msg.text.startsWith("!");
|
|
4
13
|
const text = steerPrefixed ? msg.text.slice(1).trimStart() : msg.text;
|
|
5
14
|
if (state === "idle")
|
|
6
15
|
return { action: "prompt", text };
|
|
7
|
-
if (msg.mode === "steer" || msg.mode === "followUp") {
|
|
8
|
-
return { action: msg.mode, text };
|
|
9
|
-
}
|
|
10
16
|
return { action: steerPrefixed ? "steer" : "followUp", text };
|
|
11
17
|
}
|