@timqi/pier 0.0.29 → 0.1.1
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 +58 -125
- package/dist/agent/config.js +24 -11
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +43 -62
- package/dist/agent/listing.js +113 -68
- package/dist/agent/pi.js +204 -211
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +8 -24
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +13 -35
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -260
- package/dist/core/types.js +17 -1
- package/dist/db.js +98 -252
- package/dist/drain.js +58 -51
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +6 -14
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +87 -179
- package/dist/paths.js +10 -26
- package/dist/secrets.js +19 -46
- package/dist/service.js +33 -75
- package/dist/settings.js +42 -65
- package/dist/tasks/agent.js +129 -114
- package/dist/tasks/callbacks.js +9 -19
- package/dist/tasks/command.js +29 -14
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +46 -42
- package/dist/tasks/groups.js +41 -35
- package/dist/tasks/messages.js +121 -182
- package/dist/tasks/outbox.js +61 -55
- package/dist/tasks/routes.js +5 -11
- package/dist/tasks/runs.js +14 -13
- package/dist/tasks/service.js +53 -54
- package/dist/tasks/store.js +53 -30
- package/dist/tasks/tool.js +132 -61
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +100 -327
- package/dist/update.js +21 -44
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +45 -85
- package/dist/web/providers.js +14 -13
- package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +100 -130
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +131 -180
- package/dist/web/session-state.js +14 -54
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +153 -160
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
- package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
- package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
- package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
- package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
- package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
- package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
- package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
- package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/config-sync.js
CHANGED
|
@@ -1,11 +1,93 @@
|
|
|
1
1
|
// One serialized configuration subscriber, shared by Settings and the hourly
|
|
2
2
|
// task. Only a successfully applied response advances its persistent ETag.
|
|
3
|
+
// The source is fetched over HTTPS only, redirects followed while they stay
|
|
4
|
+
// HTTPS, JSON capped at 1 MiB so a hostile or broken source cannot exhaust memory.
|
|
3
5
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { configSourceUrl, CONFIG_SYNC_BYTES, downloadConfig } from "./config-sync-fetch.js";
|
|
5
6
|
import { transact } from "./db.js";
|
|
6
7
|
import { logger } from "./log.js";
|
|
7
8
|
import { normalizeModelMenu } from "./settings.js";
|
|
8
9
|
const log = logger("config-sync");
|
|
10
|
+
export const CONFIG_SYNC_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_REDIRECTS = 5;
|
|
12
|
+
export function configSourceUrl(raw) {
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(raw.trim());
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error("A valid HTTPS source URL is required");
|
|
19
|
+
}
|
|
20
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || raw.length > 4096) {
|
|
21
|
+
throw new Error("Source must be HTTPS, without credentials or a fragment");
|
|
22
|
+
}
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
export async function downloadConfig(raw, etag, signal) {
|
|
26
|
+
let url = configSourceUrl(raw);
|
|
27
|
+
for (let hop = 0;; hop++) {
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
let res;
|
|
30
|
+
try {
|
|
31
|
+
res = await fetch(url, {
|
|
32
|
+
method: "GET", redirect: "manual", signal,
|
|
33
|
+
headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source");
|
|
38
|
+
}
|
|
39
|
+
const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
|
|
40
|
+
if (location) {
|
|
41
|
+
await res.body?.cancel().catch(() => { });
|
|
42
|
+
if (hop >= MAX_REDIRECTS)
|
|
43
|
+
throw new Error("Source redirected too many times");
|
|
44
|
+
try {
|
|
45
|
+
url = configSourceUrl(new URL(location, url).href);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error("Source redirected to a location that is not HTTPS");
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (res.status !== 200 && res.status !== 304) {
|
|
53
|
+
await res.body?.cancel().catch(() => { });
|
|
54
|
+
throw new Error(res.status === 404 || res.status === 410
|
|
55
|
+
? "Source link was revoked or does not exist"
|
|
56
|
+
: `Source returned HTTP ${String(res.status)}`);
|
|
57
|
+
}
|
|
58
|
+
const status = res.status === 304 ? 304 : 200;
|
|
59
|
+
if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
|
|
60
|
+
await res.body?.cancel().catch(() => { });
|
|
61
|
+
throw new Error("Source did not return JSON");
|
|
62
|
+
}
|
|
63
|
+
return { status, etag: res.headers.get("etag"), body: await read(res, signal) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function read(res, signal) {
|
|
67
|
+
const reader = res.body?.getReader();
|
|
68
|
+
if (!reader)
|
|
69
|
+
return "";
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let size = 0;
|
|
72
|
+
try {
|
|
73
|
+
for (;;) {
|
|
74
|
+
const { done, value } = await reader.read();
|
|
75
|
+
if (done)
|
|
76
|
+
break;
|
|
77
|
+
size += value.length;
|
|
78
|
+
if (size > CONFIG_SYNC_BYTES)
|
|
79
|
+
throw new Error("Configuration exceeds 1 MiB");
|
|
80
|
+
chunks.push(Buffer.from(value));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
await reader.cancel().catch(() => { });
|
|
85
|
+
if (err instanceof Error && err.message === "Configuration exceeds 1 MiB")
|
|
86
|
+
throw err;
|
|
87
|
+
throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Configuration download failed");
|
|
88
|
+
}
|
|
89
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
90
|
+
}
|
|
9
91
|
const KEY = "configSync";
|
|
10
92
|
// Object order is not a change; array order (model menu priority) is.
|
|
11
93
|
export function configJson(value) {
|
|
@@ -162,8 +244,8 @@ export class ConfigSync {
|
|
|
162
244
|
try {
|
|
163
245
|
await this.deps.reload();
|
|
164
246
|
}
|
|
165
|
-
catch {
|
|
166
|
-
throw new Error("Configuration saved, but reload failed; retry synchronization");
|
|
247
|
+
catch (cause) {
|
|
248
|
+
throw new Error("Configuration saved, but reload failed; retry synchronization", { cause });
|
|
167
249
|
}
|
|
168
250
|
this.#save({ needsReload: false });
|
|
169
251
|
}
|
|
@@ -171,7 +253,8 @@ export class ConfigSync {
|
|
|
171
253
|
}
|
|
172
254
|
catch (err) {
|
|
173
255
|
const error = err instanceof Error ? err.message : "Configuration sync failed";
|
|
174
|
-
log.
|
|
256
|
+
// The message is the operator's; the cause, when there is one, is the log's.
|
|
257
|
+
log.error(error, err instanceof Error ? err.cause : err);
|
|
175
258
|
this.#save({ lastChecked: Date.now(), error });
|
|
176
259
|
throw new Error(error);
|
|
177
260
|
}
|
package/dist/core/hub.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
import { logger } from "../log.js";
|
|
4
4
|
const log = logger("core");
|
|
5
5
|
const RING_SIZE = 1000;
|
|
6
|
-
/**
|
|
7
|
-
* stack (Pi's dispatch path, for session events), which must not unwind. */
|
|
6
|
+
/** emit() runs on the emitter's stack (Pi's dispatch path), which must not unwind. */
|
|
8
7
|
function fanOut(subscribers, event) {
|
|
9
8
|
for (const fn of subscribers) {
|
|
10
9
|
try {
|
|
@@ -17,8 +16,7 @@ function fanOut(subscribers, event) {
|
|
|
17
16
|
}
|
|
18
17
|
export class EventHub {
|
|
19
18
|
buses = new Map();
|
|
20
|
-
//
|
|
21
|
-
// re-lists on reconnect, so there is nothing to renumber.
|
|
19
|
+
// No seq, no replay: a client that missed events re-lists on reconnect.
|
|
22
20
|
workspace = new Set();
|
|
23
21
|
bus(sessionId) {
|
|
24
22
|
let b = this.buses.get(sessionId);
|
|
@@ -36,13 +34,9 @@ export class EventHub {
|
|
|
36
34
|
sessionId,
|
|
37
35
|
...payload,
|
|
38
36
|
};
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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.
|
|
37
|
+
// One long reply emits thousands of text deltas; a ring holding them would
|
|
38
|
+
// hold only them. `turn-end` carries the full text. Thinking stays
|
|
39
|
+
// replayable: an EventSource reconnect does not reload the transcript.
|
|
46
40
|
if (payload.type !== "text-delta") {
|
|
47
41
|
b.buffer.push(event);
|
|
48
42
|
if (b.buffer.length > RING_SIZE)
|
|
@@ -80,15 +74,8 @@ export class EventHub {
|
|
|
80
74
|
hasSubscribers(sessionId) {
|
|
81
75
|
return (this.buses.get(sessionId)?.subscribers.size ?? 0) > 0;
|
|
82
76
|
}
|
|
83
|
-
/**
|
|
84
|
-
*
|
|
85
|
-
* session leaves behind (1000 events of text, per session, forever).
|
|
86
|
-
*
|
|
87
|
-
* The bus itself stays, holding its seq: a client that reconnects with a
|
|
88
|
-
* Last-Event-ID drops anything numbered at or below what it saw, so a
|
|
89
|
-
* counter restarting at 1 would make every later event invisible to it.
|
|
90
|
-
* What is left is a number and an empty set.
|
|
91
|
-
*/
|
|
77
|
+
/** The bus keeps its seq: a client reconnecting with a Last-Event-ID drops
|
|
78
|
+
* anything numbered at or below what it saw. */
|
|
92
79
|
dropReplay(sessionId) {
|
|
93
80
|
if (this.hasSubscribers(sessionId))
|
|
94
81
|
return;
|
package/dist/core/identity.js
CHANGED
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
// Who is talking, and when — prefixed onto an inbound prompt
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// The identity is per-*turn* information and deliberately never baked into a
|
|
6
|
-
// session's instructions: a thread is shared, so pinning the first speaker
|
|
7
|
-
// misattributes everyone who follows.
|
|
8
|
-
//
|
|
9
|
-
// The whole design constraint is token cost. A header on every message is
|
|
10
|
-
// ~15 wasted tokens per turn in a DM where the counterpart never changes, so a
|
|
11
|
-
// line is emitted only when it carries news: a different speaker, or a gap long
|
|
12
|
-
// enough that "when" matters. Nothing changed means nothing is sent.
|
|
1
|
+
// Who is talking, and when — prefixed onto an inbound prompt so a group-chat
|
|
2
|
+
// session can tell speakers apart and mention them back. Per turn, never in the
|
|
3
|
+
// session's instructions (a thread is shared), and emitted only on news: a
|
|
4
|
+
// header costs ~15 tokens, wasted in a DM where the counterpart never changes.
|
|
13
5
|
/** A gap this long makes the timestamp worth its tokens. */
|
|
14
6
|
const GAP_MS = 10 * 60_000;
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
* length. Without this a display name of `x<U9] [admin<U1` forges a second
|
|
18
|
-
* speaker — the prefix is untrusted input wearing a trusted shape.
|
|
19
|
-
*/
|
|
7
|
+
/** A display name of `x<U9] [admin<U1` would forge a second speaker: the
|
|
8
|
+
* prefix is untrusted input wearing a trusted shape. */
|
|
20
9
|
export function sanitizeIdentity(value) {
|
|
21
10
|
const token = (value || "")
|
|
22
11
|
.replace(/[\r\n]+/g, " ")
|
|
@@ -27,17 +16,11 @@ export function sanitizeIdentity(value) {
|
|
|
27
16
|
const two = (n) => String(n).padStart(2, "0");
|
|
28
17
|
const hhmm = (d) => `${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
29
18
|
const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
* a change. In-memory on purpose: after a restart one redundant header is a
|
|
33
|
-
* rounding error, and persisting it would be bookkeeping for nothing.
|
|
34
|
-
*/
|
|
19
|
+
/** What each session has already been told. In memory: after a restart one
|
|
20
|
+
* redundant header is a rounding error. */
|
|
35
21
|
export class SenderPrefix {
|
|
36
22
|
seen = new Map();
|
|
37
|
-
/**
|
|
38
|
-
* The line to put above this message, or `""` when the session already knows.
|
|
39
|
-
* Same speaker, no real time gap, same day → nothing.
|
|
40
|
-
*/
|
|
23
|
+
/** The line to put above this message, or `""` when the session already knows. */
|
|
41
24
|
next(sessionId, sender, at = Date.now()) {
|
|
42
25
|
if (!sender?.id)
|
|
43
26
|
return "";
|
|
@@ -49,36 +32,24 @@ export class SenderPrefix {
|
|
|
49
32
|
const newDay = !last || day(new Date(last.at)) !== day(now);
|
|
50
33
|
if (!newSpeaker && !gap && !newDay)
|
|
51
34
|
return "";
|
|
52
|
-
// The id
|
|
53
|
-
//
|
|
54
|
-
// When the platform could not resolve a name it hands back the id, and
|
|
55
|
-
// `U123<U123>` reads as a broken record rather than as an unknown name —
|
|
56
|
-
// so an unnamed speaker is just the id, once.
|
|
35
|
+
// The id is the only thing a mention can be built from; an unresolved name
|
|
36
|
+
// is the id, and `U123<U123>` would read as a broken record.
|
|
57
37
|
const id = sanitizeIdentity(sender.id);
|
|
58
38
|
const label = sanitizeIdentity(sender.name);
|
|
59
39
|
const who = newSpeaker ? (label === id ? `<${id}>` : `${label}<${id}>`) : "";
|
|
60
|
-
// The date only when it changed; inside one conversation-day it is noise.
|
|
61
40
|
const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
|
|
62
41
|
return `[${[who, when].filter(Boolean).join(" ")}]`;
|
|
63
42
|
}
|
|
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
43
|
forget(sessionId) {
|
|
67
44
|
this.seen.delete(sessionId);
|
|
68
45
|
}
|
|
69
46
|
}
|
|
70
|
-
/** Put the prefix above the message, or hand the message back untouched. */
|
|
71
47
|
export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
|
|
72
|
-
// Only the shapes `next()` emits
|
|
73
|
-
// `
|
|
74
|
-
// markdown link opening the message or a human typing `[14:23] on my way`, is
|
|
75
|
-
// body text and must come back untouched.
|
|
48
|
+
// Only the shapes `next()` emits, newline included: a human typing
|
|
49
|
+
// `[14:23] on my way` is body text and must come back untouched.
|
|
76
50
|
const HEADER = /^\[(?:([^\n[\]<>]*)<([^\n[\]<>]+)>)? ?((?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})?\]\n/;
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
* the model, not something a human should have to read: a surface showing a
|
|
80
|
-
* stored message can render the speaker as it likes and the body without it.
|
|
81
|
-
*/
|
|
51
|
+
/** Read back a header this module wrote: the prefix is for the model, and a
|
|
52
|
+
* surface showing a stored message renders the speaker its own way. */
|
|
82
53
|
export function splitSpeaker(text) {
|
|
83
54
|
const m = HEADER.exec(text);
|
|
84
55
|
if (!m?.[2] && !m?.[3])
|
|
@@ -96,25 +67,15 @@ const ATTACHMENT_LINES = /(?:\n\[[^\]\n]*(?:\]\(\s*<?file:\/\/[^\n]*|$))+$/;
|
|
|
96
67
|
/** A long code span that opens the message, when something else follows it.
|
|
97
68
|
* Long, because `npm test` before "fails" is the subject, not a paste. */
|
|
98
69
|
const LEADING_CODE_SPAN = /^\s*`[^`\n]{40,}`\s+(\S[\s\S]*)$/;
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* the workbench ever opened. So the speaker comes off and what they said is
|
|
104
|
-
* the title. Here rather than in a UI module because the push notification
|
|
105
|
-
* needs the same answer and a second copy of this would drift (AGENTS.md §3).
|
|
106
|
-
*
|
|
107
|
-
* Two more things a first prompt carries that a title should not: the
|
|
108
|
-
* attachment lines a channel appended (`[name](file:///…)`, often cut mid-name
|
|
109
|
-
* by the title clip), and a pasted log line in backticks ahead of the actual
|
|
110
|
-
* question — dropped only when a question follows; a paste alone stays.
|
|
111
|
-
*/
|
|
70
|
+
/** A title derived from a first prompt drops what was for the model, not the
|
|
71
|
+
* reader: the speaker header, the attachment lines a channel appended, and a
|
|
72
|
+
* pasted code span ahead of the actual question. Shared by list rows and push
|
|
73
|
+
* notifications, hence core. */
|
|
112
74
|
export function readableTitle(title) {
|
|
113
75
|
if (!title)
|
|
114
76
|
return title;
|
|
115
77
|
const { text } = splitSpeaker(title);
|
|
116
|
-
// No header
|
|
117
|
-
// change what the sidebar's search is matching against for nothing.
|
|
78
|
+
// No header: reflowing what the person typed would change what search matches.
|
|
118
79
|
if (text === title)
|
|
119
80
|
return title;
|
|
120
81
|
const said = text
|
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
// The file-link
|
|
2
|
-
//
|
|
3
|
-
// splits it back out of a message, the size cap both ends enforce, and where a
|
|
4
|
-
// `file://` link does not count as one at all (inside code).
|
|
5
|
-
// Producers are node code (channels/, web/server.ts) but the web composer
|
|
6
|
-
// builds markers and the web chat parses them in the browser, so the grammar
|
|
7
|
-
// lives in a module with no node imports that either side can load. The
|
|
8
|
-
// filesystem half (saving the bytes) is core/inbox.ts.
|
|
1
|
+
// The inbound file-link grammar. No node imports: the browser composer builds
|
|
2
|
+
// markers and the web chat parses them. Saving the bytes is core/inbox.ts.
|
|
9
3
|
/** One cap for every inbound path: composer, upload route, Slack metadata. */
|
|
10
4
|
export const MAX_INBOUND_BYTES = 32 * 1024 * 1024;
|
|
11
5
|
/** Extension for a name-less upload (a pasted screenshot has no filename). */
|
|
@@ -17,11 +11,7 @@ const MIME_EXT = {
|
|
|
17
11
|
"application/pdf": ".pdf",
|
|
18
12
|
"text/plain": ".txt",
|
|
19
13
|
};
|
|
20
|
-
/**
|
|
21
|
-
* A filename that is safe as a path segment and inside a markdown link:
|
|
22
|
-
* basename only (no traversal), whitespace and link-breaking characters
|
|
23
|
-
* folded to `-`, length capped.
|
|
24
|
-
*/
|
|
14
|
+
/** Safe as a path segment (no traversal) and inside a markdown link. */
|
|
25
15
|
export function safeName(name, mimeType) {
|
|
26
16
|
const base = (name ?? "").split("/").pop().replace(/[\s\\()[\]<>%#?]/g, "-");
|
|
27
17
|
if (!base || base === "." || base === "..")
|
|
@@ -32,29 +22,16 @@ export function safeName(name, mimeType) {
|
|
|
32
22
|
const ext = dot > 0 ? base.slice(dot, dot + 16) : "";
|
|
33
23
|
return base.slice(0, 64 - ext.length) + ext;
|
|
34
24
|
}
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
* path is percent-encoded (parentheses included, which encodeURI leaves
|
|
38
|
-
* alone) so the link survives markdown and the marker regex even when
|
|
39
|
-
* `PIER_HOME` contains spaces or parens; splitInboundFiles decodes.
|
|
40
|
-
*/
|
|
25
|
+
/** Percent-encoded, parentheses included (encodeURI leaves them), so the link
|
|
26
|
+
* survives markdown when `PIER_HOME` contains spaces or parens. */
|
|
41
27
|
export const fileMarker = (path) => `[${path.split("/").pop() ?? "file"}](file://${encodeURI(path).replace(/\(/g, "%28").replace(/\)/g, "%29")})`;
|
|
42
|
-
/**
|
|
43
|
-
*
|
|
44
|
-
* failed download must not look like no attachment). Plain text on purpose —
|
|
45
|
-
* not a link — so every surface renders it as the words it is. Both
|
|
46
|
-
* directions: an inbound file Pier could not fetch and an outbound one it
|
|
47
|
-
* could not upload (channels/attach.ts) are the same fact to the reader.
|
|
48
|
-
*/
|
|
28
|
+
/** A failed download must not look like no attachment (§5). Plain text, not a
|
|
29
|
+
* link, so every surface renders the words; used in both directions. */
|
|
49
30
|
export const lostMarker = (name, reason) => `[attachment lost: ${name} — ${reason}]`;
|
|
50
31
|
/** A fenced block's opening or closing line. */
|
|
51
32
|
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
* included) and inline spans. Inline spans are paired within a line — one that
|
|
55
|
-
* wraps across a newline is legal markdown and not how anyone writes an
|
|
56
|
-
* example link, and per-line pairing keeps this a scan instead of a parser.
|
|
57
|
-
*/
|
|
33
|
+
/** Code ranges: fenced blocks and inline spans. Spans are paired within a line,
|
|
34
|
+
* which keeps this a scan instead of a parser. */
|
|
58
35
|
function codeRanges(text) {
|
|
59
36
|
const ranges = [];
|
|
60
37
|
let fence;
|
|
@@ -71,8 +48,8 @@ function codeRanges(text) {
|
|
|
71
48
|
ranges.push([at, at + line.length]);
|
|
72
49
|
}
|
|
73
50
|
else {
|
|
74
|
-
// A span closes on the next
|
|
75
|
-
//
|
|
51
|
+
// A span closes on the next run of the same length; an unpaired opener
|
|
52
|
+
// leaves the rest as prose.
|
|
76
53
|
const runs = [...line.matchAll(/`+/g)];
|
|
77
54
|
for (let i = 0; i < runs.length; i++) {
|
|
78
55
|
const open = runs[i];
|
|
@@ -89,13 +66,8 @@ function codeRanges(text) {
|
|
|
89
66
|
}
|
|
90
67
|
return ranges;
|
|
91
68
|
}
|
|
92
|
-
/**
|
|
93
|
-
*
|
|
94
|
-
* that documents this convention writes an example link in backticks, and a
|
|
95
|
-
* scanner that cannot tell an example from a link turned that example into a
|
|
96
|
-
* real attachment — a dead card in the chat, a lost-attachment line in Slack.
|
|
97
|
-
* Matches inside code are left byte-identical: the reader asked to see them.
|
|
98
|
-
*/
|
|
69
|
+
/** `text.replace(pattern, …)` outside code only: an example link in backticks
|
|
70
|
+
* is not an attachment, and the reader asked to see it byte-identical. */
|
|
99
71
|
export function replaceOutsideCode(text, pattern, replace) {
|
|
100
72
|
const skip = codeRanges(text);
|
|
101
73
|
let out = "";
|
|
@@ -112,14 +84,8 @@ export function replaceOutsideCode(text, pattern, replace) {
|
|
|
112
84
|
}
|
|
113
85
|
// A whole line that is one `[name](file:///…)` link — what fileMarker emits.
|
|
114
86
|
const MARKER_RE = /^\[[^\]\n]*\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)$/;
|
|
115
|
-
/**
|
|
116
|
-
*
|
|
117
|
-
* Only the contiguous *trailing* block of marker lines is an attachment —
|
|
118
|
-
* that is where every producer puts them — so a `file://` link the user
|
|
119
|
-
* wrote mid-message stays message text. No code scan needed for the same
|
|
120
|
-
* reason: an example in a fence sits under its closing line, which is not a
|
|
121
|
-
* marker line, so the walk stops there before ever reaching it.
|
|
122
|
-
*/
|
|
87
|
+
/** Only the contiguous trailing block of marker lines is an attachment, so a
|
|
88
|
+
* `file://` link mid-message stays text and a fenced example is never reached. */
|
|
123
89
|
export function splitInboundFiles(raw) {
|
|
124
90
|
const lines = raw.split("\n");
|
|
125
91
|
let start = lines.length;
|
package/dist/core/inbox.js
CHANGED
|
@@ -1,42 +1,27 @@
|
|
|
1
|
-
// Inbound user files: bytes land on disk
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
1
|
+
// Inbound user files: bytes land on disk once under `$PIER_HOME/inbox/<channel>/`
|
|
2
|
+
// and the prompt carries only a marker line, so a file is never re-sent with
|
|
3
|
+
// every provider request and the agent reads it only when it chooses to.
|
|
9
4
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
5
|
import { randomBytes } from "node:crypto";
|
|
11
6
|
import { basename, join } from "node:path";
|
|
12
7
|
import { pierPath } from "../paths.js";
|
|
13
8
|
import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
|
|
14
9
|
/** Where every inbound file lives; the attachment route allowlists this root. */
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*/
|
|
10
|
+
const INBOX_DIR = pierPath("inbox");
|
|
11
|
+
/** `wx` turns a prefix collision into an error, not an overwrite. Owner-only
|
|
12
|
+
* modes: uploads are private content on a possibly shared machine. Nothing is
|
|
13
|
+
* deleted here; pruning is the operator's call (docs/deploy.md). */
|
|
24
14
|
export async function saveInbound(channelId, name, mimeType, bytes) {
|
|
25
|
-
// The channel id is ours
|
|
26
|
-
// but basename() keeps a future id honest.
|
|
15
|
+
// The channel id is ours, not user input; basename() keeps a future id honest.
|
|
27
16
|
const dir = join(INBOX_DIR, basename(channelId));
|
|
28
17
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
18
|
const path = join(dir, `${String(Date.now())}-${randomBytes(3).toString("hex")}-${safeName(name, mimeType)}`);
|
|
30
19
|
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
31
20
|
return path;
|
|
32
21
|
}
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* metadata — absent or wrong, `arrayBuffer()` buffers whatever arrives — so
|
|
37
|
-
* the read itself is bounded too. Throws with "too large" in the message,
|
|
38
|
-
* which the loop below translates into the honest lost-marker reason.
|
|
39
|
-
*/
|
|
22
|
+
/** The metadata size gate is only as honest as the platform's metadata, so the
|
|
23
|
+
* read itself is bounded too. Throws with "too large" in the message, which
|
|
24
|
+
* saveInboundAll translates into the lost-marker reason. */
|
|
40
25
|
export async function readCapped(body, maxBytes) {
|
|
41
26
|
if (!body)
|
|
42
27
|
return new Uint8Array(0);
|
|
@@ -67,14 +52,8 @@ export async function readCapped(body, maxBytes) {
|
|
|
67
52
|
}
|
|
68
53
|
return bytes;
|
|
69
54
|
}
|
|
70
|
-
/**
|
|
71
|
-
*
|
|
72
|
-
* and a failed or oversized one becomes a lost-marker line, never silence
|
|
73
|
-
* (5b). Written three times, once per adapter, before landing here: the
|
|
74
|
-
* size gate before the fetch (an unauthorized sender is already filtered by
|
|
75
|
-
* then, but a movie must not be buffered whole either) and the never-silent
|
|
76
|
-
* failure path are invariants, and invariants drift when copied.
|
|
77
|
-
*/
|
|
55
|
+
/** Each attachment becomes a marker line; a failed or oversized one becomes a
|
|
56
|
+
* lost-marker line, never silence (§5). The size gate runs before the fetch. */
|
|
78
57
|
export async function saveInboundAll(channelId, files, log) {
|
|
79
58
|
const markers = [];
|
|
80
59
|
for (const file of files) {
|
|
@@ -89,7 +68,6 @@ export async function saveInboundAll(channelId, files, log) {
|
|
|
89
68
|
}
|
|
90
69
|
catch (err) {
|
|
91
70
|
log(`attachment download failed: ${String(err)}`);
|
|
92
|
-
// A fetch that refused mid-stream names its reason; keep it honest.
|
|
93
71
|
const why = String(err).includes("too large") ? "too large" : "download failed";
|
|
94
72
|
markers.push(lostMarker(file.label, why));
|
|
95
73
|
}
|
package/dist/core/queue.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// The whole queue policy. Fixed by docs/architecture.md — do not add options.
|
|
2
2
|
export function decide(msg, state) {
|
|
3
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.
|
|
4
|
+
// message, so a leading "!" there is content, not a control prefix.
|
|
6
5
|
if (msg.mode === "steer" || msg.mode === "followUp") {
|
|
7
6
|
return { action: state === "idle" ? "prompt" : msg.mode, text: msg.text };
|
|
8
7
|
}
|
|
9
|
-
// On auto the "!" is
|
|
10
|
-
//
|
|
11
|
-
// means there is nothing to steer, so it degenerates to a prompt.
|
|
8
|
+
// On auto the "!" is always consumed: whether the turn happened to end
|
|
9
|
+
// first must not decide if it was content.
|
|
12
10
|
const steerPrefixed = msg.text.startsWith("!");
|
|
13
11
|
const text = steerPrefixed ? msg.text.slice(1).trimStart() : msg.text;
|
|
14
12
|
if (state === "idle")
|