@timqi/pier 0.1.0 → 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 +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- 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 +7 -23
- 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 +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- 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 +4 -12
- 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 +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- 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 +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
- 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-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
- 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-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
- 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-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
- 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-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
- 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 +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.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 +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -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 +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/boards/boards.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
// Boards:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Only <board>/site is reachable over HTTP: sources, README and the manifest
|
|
6
|
-
// itself stay off the wire, so a public board leaks nothing about how it was
|
|
7
|
-
// made. `/boards/*` is authenticated; `/p/*` additionally requires the
|
|
8
|
-
// manifest's `public` flag and runs as sandboxed active content — and is the
|
|
9
|
-
// only password-free prefix, stylesheet included, so one firewall rule covers
|
|
10
|
-
// everything a logged-out reader may fetch.
|
|
1
|
+
// Boards: static pages an agent writes, derived by scanning $PIER_HOME/boards,
|
|
2
|
+
// never registered (docs/design/05-boards.md). Only <board>/site is reachable
|
|
3
|
+
// over HTTP, so a public board leaks nothing about how it was made. `/p/*` is
|
|
4
|
+
// the only password-free prefix, stylesheet included, and runs sandboxed.
|
|
11
5
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
6
|
import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
13
7
|
import { extname, join, resolve, sep } from "node:path";
|
|
@@ -17,11 +11,8 @@ export const defaultBoardsDir = () => pierPath("boards");
|
|
|
17
11
|
/** Deleted boards keep their bytes under `<slug>.deleted-<ts>`, which this
|
|
18
12
|
* pattern excludes from every scan — one rename is the whole delete path. */
|
|
19
13
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
* a dictionary. Minted the first time a manifest is seen public, by whichever
|
|
23
|
-
* path published it — the Console's toggle or an agent editing board.json —
|
|
24
|
-
* so a board cannot be public and enumerable at the same time. */
|
|
14
|
+
/** Without the 32 bits after the slug, `/p/` could be walked with a dictionary.
|
|
15
|
+
* Minted the first time a manifest is seen public, by whichever path did it. */
|
|
25
16
|
const TOKEN = /^[a-f0-9]{8}$/;
|
|
26
17
|
const mintToken = () => randomBytes(4).toString("hex");
|
|
27
18
|
// A board ships fonts and images, so the list is wider than the attachment
|
|
@@ -43,10 +34,11 @@ const TYPES = {
|
|
|
43
34
|
".txt": "text/plain; charset=utf-8",
|
|
44
35
|
".csv": "text/plain; charset=utf-8",
|
|
45
36
|
};
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
37
|
+
// Active content on the workbench origin: the sandbox removes forms, frames,
|
|
38
|
+
// popups and subresource requests. A private board keeps `allow-same-origin`
|
|
39
|
+
// for the session cookie, so its script reads this origin's storage and
|
|
40
|
+
// top-level navigation remains a way out (docs/design/05-boards.md). Public
|
|
41
|
+
// pages omit same-origin, so their scripts inherit no authority at all.
|
|
50
42
|
const CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " +
|
|
51
43
|
"script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; " +
|
|
52
44
|
"worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; " +
|
|
@@ -55,14 +47,9 @@ const PRIVATE_CSP = `sandbox allow-scripts allow-same-origin; ${CSP}`;
|
|
|
55
47
|
const PUBLIC_CSP = `sandbox allow-scripts; ${CSP}`;
|
|
56
48
|
/** Malformed boards are reported once, not on every scan. */
|
|
57
49
|
const warned = new Set();
|
|
58
|
-
/** The one
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* defaults, a broken file is skipped whole, and extra keys are the agent's
|
|
62
|
-
* business — they survive a write. The one manifest this writes back is a
|
|
63
|
-
* public board that arrived without a token: minting is the same decision as
|
|
64
|
-
* reading `public`, and doing it anywhere else leaves the agent's own publish
|
|
65
|
-
* path — editing `board.json` — with no URL. */
|
|
50
|
+
/** The one place a slug becomes a path, so it is validated here (`../../etc`,
|
|
51
|
+
* NUL). Extra keys survive a write. The one write-back is minting a token for
|
|
52
|
+
* a public board that arrived without one, so the agent's own publish path has a URL. */
|
|
66
53
|
async function readManifest(dir, slug) {
|
|
67
54
|
if (!SLUG.test(slug))
|
|
68
55
|
return null;
|
|
@@ -119,8 +106,11 @@ export async function listBoards(dir) {
|
|
|
119
106
|
.filter((e) => e.isDirectory() && SLUG.test(e.name))
|
|
120
107
|
.map((e) => e.name);
|
|
121
108
|
}
|
|
122
|
-
catch {
|
|
123
|
-
|
|
109
|
+
catch (err) {
|
|
110
|
+
// No directory is no boards yet; anything else hides every board at once.
|
|
111
|
+
if (err.code !== "ENOENT")
|
|
112
|
+
logger("boards").warn(`cannot scan ${dir}`, err);
|
|
113
|
+
return [];
|
|
124
114
|
}
|
|
125
115
|
// One board's manifest says nothing about the next one's, so the scan waits
|
|
126
116
|
// once for all of them rather than once per board.
|
package/dist/channels/attach.js
CHANGED
|
@@ -1,43 +1,21 @@
|
|
|
1
|
-
// Outbound attachments:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// convention core/reply.ts hands it, spelled the same way inbound
|
|
6
|
-
// (core/inbound-file.ts). The web chat renders that link as a card because the
|
|
7
|
-
// browser can fetch the bytes back over an authenticated route; an IM client
|
|
8
|
-
// cannot, and a `file:///…` link in Slack is a dead path on someone else's
|
|
9
|
-
// machine. So an adapter uploads the file to the platform instead, and the
|
|
10
|
-
// link's label stays behind as the words around it.
|
|
11
|
-
//
|
|
12
|
-
// The upload itself is per-platform and stays in each `*-api.ts`; what is
|
|
13
|
-
// shared — the grammar, the caps, and the line a failed attachment still owes
|
|
14
|
-
// the conversation — lives here so three adapters do not each have a copy.
|
|
1
|
+
// Outbound attachments: a `file://` link in a turn is a dead path on someone
|
|
2
|
+
// else's machine, so an IM adapter uploads the bytes and keeps the label. The
|
|
3
|
+
// upload is per-platform; the grammar, the caps and the line a failed
|
|
4
|
+
// attachment still owes the conversation are shared here.
|
|
15
5
|
import { readFile, stat } from "node:fs/promises";
|
|
16
6
|
import { basename, extname } from "node:path";
|
|
17
7
|
import { lostMarker, replaceOutsideCode } from "../core/inbound-file.js";
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
* the smallest of the three, and a turn that lands on one chat and not on
|
|
21
|
-
* another is worse than a turn that is honest everywhere.
|
|
22
|
-
*/
|
|
8
|
+
/** Telegram refuses a photo past 10 MB, the smallest of the three; one cap so
|
|
9
|
+
* a turn does not land on one chat and not another. */
|
|
23
10
|
export const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
|
|
24
|
-
/** Per turn. Linking a directory's worth of files is a mistake, not a plan. */
|
|
25
11
|
const MAX_ATTACHMENTS = 5;
|
|
26
|
-
/**
|
|
27
|
-
* svg included, deliberately: it is markup, and it renders as a file. */
|
|
12
|
+
/** svg excluded deliberately: it is markup, and renders as a file. */
|
|
28
13
|
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
|
29
|
-
/** `[label](file:///abs/path)
|
|
30
|
-
* `!` is an image embed, which is the same request with a different sigil. */
|
|
14
|
+
/** `[label](file:///abs/path)`; a leading `!` is the same request as an image embed. */
|
|
31
15
|
const LINK = /!?\[([^\]\n]*)\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)/g;
|
|
32
|
-
/** A trailing slash or a bare root would otherwise leave an unnamed file. */
|
|
33
16
|
const nameOf = (path) => basename(path) || "file";
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
* it linked. Each link collapses to its label — or to the file's name when the
|
|
37
|
-
* agent wrote none — so the sentence it sat in still reads, and the turn never
|
|
38
|
-
* becomes empty just because its only content was an attachment. A link inside
|
|
39
|
-
* code is an example of the convention, not a use of it: it is left alone.
|
|
40
|
-
*/
|
|
17
|
+
/** Each link collapses to its label (or the file's name), so the sentence it
|
|
18
|
+
* sat in still reads. A link inside code is an example, left alone. */
|
|
41
19
|
export function splitAttachments(markdown) {
|
|
42
20
|
const paths = [];
|
|
43
21
|
const text = replaceOutsideCode(markdown, LINK, (match) => {
|
|
@@ -56,12 +34,8 @@ export function splitAttachments(markdown) {
|
|
|
56
34
|
});
|
|
57
35
|
return { text, paths };
|
|
58
36
|
}
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
* owes: an attachment that never arrived must not look like an attachment that
|
|
62
|
-
* was never mentioned (AGENTS.md 5b), so each failure is named in the chat as
|
|
63
|
-
* well as in the log. Empty string when everything landed.
|
|
64
|
-
*/
|
|
37
|
+
/** Returns the line the conversation still owes: an attachment that never
|
|
38
|
+
* arrived must not look like one never mentioned (§5). "" when all landed. */
|
|
65
39
|
export async function sendAttachments(paths, upload, log) {
|
|
66
40
|
const lost = [];
|
|
67
41
|
const fail = (path, reason) => {
|
|
@@ -69,10 +43,8 @@ export async function sendAttachments(paths, upload, log) {
|
|
|
69
43
|
lost.push(lostMarker(nameOf(path), reason));
|
|
70
44
|
};
|
|
71
45
|
const taken = paths.slice(0, MAX_ATTACHMENTS);
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// turn linked them, and each failure is kept in its own slot — `lost` reads
|
|
75
|
-
// in link order whichever upload finished first.
|
|
46
|
+
// Two phases: reads in parallel, then uploads, so the platform receives
|
|
47
|
+
// files in link order and `lost` reads in link order too.
|
|
76
48
|
const reasons = [];
|
|
77
49
|
const reasonOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
78
50
|
const files = await Promise.all(taken.map(async (path, i) => {
|
package/dist/channels/chains.js
CHANGED
|
@@ -1,28 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Messages from different chats are handled concurrently — a slow download must
|
|
4
|
-
// not stall another group — but messages within one chat strictly in arrival
|
|
5
|
-
// order, because a steer overtaking the message it interrupts reorders the
|
|
6
|
-
// conversation.
|
|
7
|
-
//
|
|
8
|
-
// Two rules live here because both adapters got to write them once and neither
|
|
9
|
-
// should write them twice:
|
|
10
|
-
//
|
|
11
|
-
// - Every link needs its own `catch`. One rejected handler otherwise poisons
|
|
12
|
-
// the chain and silences that chat for the life of the process — an ordering
|
|
13
|
-
// mechanism that fails closed, permanently.
|
|
14
|
-
// - `drain()` is bounded. `runtime.reload()` runs on the Console's save, so a
|
|
15
|
-
// stuck handler must not hold that request open.
|
|
1
|
+
// One promise chain per conversation: chats run concurrently, but within one
|
|
2
|
+
// chat strictly in arrival order, or a steer overtakes the message it interrupts.
|
|
16
3
|
export class Chains {
|
|
17
4
|
log;
|
|
18
5
|
maxActive;
|
|
19
6
|
active = new Map();
|
|
7
|
+
held = 0;
|
|
8
|
+
waiting = [];
|
|
20
9
|
constructor(log,
|
|
21
|
-
/**
|
|
22
|
-
* How many conversations may be in flight before a new one queues behind an
|
|
23
|
-
* existing chain. Bounds concurrency (open sockets, downloads); a transport
|
|
24
|
-
* that can also slow its source down should do that separately.
|
|
25
|
-
*/
|
|
10
|
+
/** Bounds concurrency (sockets, downloads), not the source's backlog. */
|
|
26
11
|
maxActive = Infinity) {
|
|
27
12
|
this.log = log;
|
|
28
13
|
this.maxActive = maxActive;
|
|
@@ -30,34 +15,45 @@ export class Chains {
|
|
|
30
15
|
get size() {
|
|
31
16
|
return this.active.size;
|
|
32
17
|
}
|
|
33
|
-
/** Append to a conversation's chain, dropping the entry once it drains. */
|
|
34
18
|
run(key, task) {
|
|
19
|
+
// A conversation owns one slot for the life of its chain, so a follow-up
|
|
20
|
+
// message queues behind its own handler instead of claiming a second.
|
|
35
21
|
const mine = this.active.get(key);
|
|
36
|
-
|
|
37
|
-
// to wait for a slot. `active` is non-empty whenever the cap is hit, so the
|
|
38
|
-
// race always settles.
|
|
39
|
-
const start = mine ??
|
|
40
|
-
(this.active.size >= this.maxActive
|
|
41
|
-
? Promise.race(this.active.values()).catch(() => { })
|
|
42
|
-
: Promise.resolve());
|
|
43
|
-
const next = start
|
|
22
|
+
const next = (mine ?? this.acquire())
|
|
44
23
|
.then(task)
|
|
24
|
+
// Every link catches: one rejection would otherwise silence the chat for good.
|
|
45
25
|
.catch((err) => this.log(`handler failed in ${key}: ${String(err)}`));
|
|
46
26
|
this.active.set(key, next);
|
|
47
27
|
void next.then(() => {
|
|
48
|
-
if (this.active.get(key)
|
|
49
|
-
|
|
28
|
+
if (this.active.get(key) !== next)
|
|
29
|
+
return;
|
|
30
|
+
this.active.delete(key);
|
|
31
|
+
this.release();
|
|
50
32
|
});
|
|
51
33
|
}
|
|
52
|
-
/**
|
|
34
|
+
/** Reserved synchronously, so a burst of `run` calls cannot all pass the cap. */
|
|
35
|
+
acquire() {
|
|
36
|
+
if (this.held < this.maxActive) {
|
|
37
|
+
this.held += 1;
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
}
|
|
40
|
+
return new Promise((resolve) => this.waiting.push(resolve));
|
|
41
|
+
}
|
|
42
|
+
release() {
|
|
43
|
+
// The slot moves to one waiter rather than being counted back, which is
|
|
44
|
+
// what keeps a release from waking the whole queue.
|
|
45
|
+
const next = this.waiting.shift();
|
|
46
|
+
if (next)
|
|
47
|
+
next();
|
|
48
|
+
else
|
|
49
|
+
this.held -= 1;
|
|
50
|
+
}
|
|
51
|
+
/** The backpressure primitive. */
|
|
53
52
|
oldest() {
|
|
54
53
|
return Promise.race(this.active.values()).catch(() => { });
|
|
55
54
|
}
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
* wait forever: two adapters handling one message would prompt twice, and a
|
|
59
|
-
* hung handler holding up a config save is worse than either.
|
|
60
|
-
*/
|
|
55
|
+
/** Two adapters handling one message would prompt twice; a hung handler
|
|
56
|
+
* holding up the Console's save is worse than either, hence the bound. */
|
|
61
57
|
async drain(timeoutMs) {
|
|
62
58
|
await Promise.race([
|
|
63
59
|
Promise.allSettled(this.active.values()),
|
package/dist/channels/chunk.js
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
// Splitting a long turn into sendable messages
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// that having it twice is having it wrong once. The cap and any per-platform
|
|
6
|
-
// repair (Slack re-balances code fences across the cut) stay with the renderer.
|
|
7
|
-
/**
|
|
8
|
-
* Cut `text` into pieces of at most `max` characters, preferring a blank line,
|
|
9
|
-
* then a newline, then the hard limit. A cut lands mid-text only when there is
|
|
10
|
-
* no break in the second half of the window — better a blunt split than a
|
|
11
|
-
* dropped turn.
|
|
12
|
-
*/
|
|
1
|
+
// Splitting a long turn into sendable messages; the cap and any per-platform
|
|
2
|
+
// repair stay with the renderer.
|
|
3
|
+
/** Prefers a blank line, then a newline, then the hard limit; a cut lands
|
|
4
|
+
* mid-text only when there is no break in the second half of the window. */
|
|
13
5
|
export function chunkText(text, max) {
|
|
14
6
|
if (text.length <= max)
|
|
15
7
|
return [text];
|
|
@@ -26,27 +18,15 @@ export function chunkText(text, max) {
|
|
|
26
18
|
parts.push(rest);
|
|
27
19
|
return parts;
|
|
28
20
|
}
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* and Lark both swallow the rest of a message after an unterminated ```, and
|
|
33
|
-
* the next chunk starts *outside* a fence, so the tail of a long code block
|
|
34
|
-
* renders as prose.
|
|
35
|
-
*
|
|
36
|
-
* Fences are tracked by line-leading runs with their *length*, per CommonMark:
|
|
37
|
-
* a ```` fence (used to quote a ``` block) only closes on a run at least as
|
|
38
|
-
* long, so counting bare ``` occurrences would see the inner block close the
|
|
39
|
-
* outer one and mangle both halves of the cut.
|
|
40
|
-
*/
|
|
21
|
+
/** Slack and Lark swallow the rest of a message after an unterminated ```, so a
|
|
22
|
+
* fence left open is closed and reopened on the next chunk. Runs are tracked
|
|
23
|
+
* by length, per CommonMark: a ```` fence only closes on a run at least as long. */
|
|
41
24
|
export function balanceFences(parts) {
|
|
42
|
-
/** Backticks of the fence currently open across the boundary; 0 = closed. */
|
|
43
25
|
let open = 0;
|
|
44
26
|
const fence = (n) => "`".repeat(n);
|
|
45
27
|
return parts.map((part) => {
|
|
46
28
|
const reopened = open ? `${fence(open)}\n${part}` : part;
|
|
47
|
-
// The
|
|
48
|
-
// reads it as the opener, so a chunk that closes the block it inherited
|
|
49
|
-
// comes out even.
|
|
29
|
+
// The scan restarts from "closed" and reads the prepended fence as the opener.
|
|
50
30
|
open = 0;
|
|
51
31
|
for (const line of reopened.split("\n")) {
|
|
52
32
|
const run = /^\s*(`{3,})/.exec(line)?.[1]?.length ?? 0;
|
|
@@ -1,21 +1,10 @@
|
|
|
1
|
-
// Slash commands in IM text, parsed once for every platform
|
|
2
|
-
//
|
|
3
|
-
// Every chat platform types commands the same way and mangles them slightly
|
|
4
|
-
// differently: Telegram appends `@botname` when several bots share a group,
|
|
5
|
-
// clients add stray whitespace, and users capitalise. One parser so an adapter
|
|
6
|
-
// never re-derives "is this a command" and the answer never drifts between
|
|
7
|
-
// platforms.
|
|
8
|
-
/**
|
|
9
|
-
* Parse a command out of one inbound message. Whitespace on both ends is
|
|
10
|
-
* dropped first, then a leading `/` is required: anything else is ordinary
|
|
11
|
-
* text and must reach the agent untouched.
|
|
12
|
-
*/
|
|
1
|
+
// Slash commands in IM text, parsed once for every platform: Telegram appends
|
|
2
|
+
// `@botname` when several bots share a group, clients add whitespace, users capitalise.
|
|
13
3
|
export function parseCommand(text) {
|
|
14
4
|
const trimmed = text.trim();
|
|
15
5
|
if (!trimmed.startsWith("/"))
|
|
16
6
|
return null;
|
|
17
|
-
// Args
|
|
18
|
-
// sentence must survive with its own spacing intact.
|
|
7
|
+
// Args verbatim: a path or a sentence must keep its own spacing.
|
|
19
8
|
const match = /^\/(\S+)[ \t]*([\s\S]*)$/.exec(trimmed);
|
|
20
9
|
const [name = "", target] = (match?.[1] ?? "").split("@");
|
|
21
10
|
if (!name)
|
package/dist/channels/config.js
CHANGED
|
@@ -1,31 +1,25 @@
|
|
|
1
|
-
// Channel config persistence and the permission gate every adapter shares
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
// surface configuring a platform reads and writes exactly one row.
|
|
5
|
-
// The shapes live in types.ts; this file is the store and the policy.
|
|
1
|
+
// Channel config persistence and the permission gate every adapter shares: one
|
|
2
|
+
// JSON document per platform, so a surface configuring one reads and writes one row.
|
|
3
|
+
import { randomInt } from "node:crypto";
|
|
6
4
|
import { pierDb } from "../db.js";
|
|
5
|
+
import { isSealed } from "../secrets.js";
|
|
7
6
|
import { defaultChannelConfig, } from "./types.js";
|
|
8
7
|
const BIND_CODE_TTL_MS = 10 * 60_000;
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
const
|
|
8
|
+
/** Six symbols out of 36 is 2 billion, but a caller who may retry forever only
|
|
9
|
+
* needs the TTL; five wrong tries void the code instead. */
|
|
10
|
+
const BIND_CODE_TRIES = 5;
|
|
11
|
+
const BIND_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
12
12
|
export class ChannelStore {
|
|
13
13
|
secrets;
|
|
14
14
|
db;
|
|
15
15
|
cache = new Map();
|
|
16
|
-
/** Without `secrets
|
|
17
|
-
* With it, tokens are sealed at rest; a locked store throws on both paths
|
|
16
|
+
/** Without `secrets` (tests), tokens persist as given. A locked store throws
|
|
18
17
|
* rather than serving a token it cannot read. */
|
|
19
18
|
constructor(db = pierDb(), secrets) {
|
|
20
19
|
this.secrets = secrets;
|
|
21
20
|
this.db = db;
|
|
22
21
|
}
|
|
23
|
-
/**
|
|
24
|
-
* The live cached document. Private on purpose: handing it out let a caller
|
|
25
|
-
* mutate config without saving, so memory and disk could disagree with no
|
|
26
|
-
* way to tell which was right. Internal readers use this; everyone outside
|
|
27
|
-
* gets a copy from get().
|
|
28
|
-
*/
|
|
22
|
+
/** Private: handed out, a caller could mutate config without saving. */
|
|
29
23
|
cached(platform) {
|
|
30
24
|
const hit = this.cache.get(platform);
|
|
31
25
|
if (hit)
|
|
@@ -36,23 +30,20 @@ export class ChannelStore {
|
|
|
36
30
|
: defaultChannelConfig();
|
|
37
31
|
if (this.secrets) {
|
|
38
32
|
for (const key of ["token", "appToken"]) {
|
|
39
|
-
if (
|
|
33
|
+
if (isSealed(config[key]))
|
|
40
34
|
config[key] = this.secrets.decrypt(config[key]);
|
|
41
35
|
}
|
|
42
36
|
}
|
|
43
37
|
this.cache.set(platform, config);
|
|
44
38
|
return config;
|
|
45
39
|
}
|
|
46
|
-
/** A detached copy: edit it freely, then hand it back to save(). */
|
|
47
40
|
get(platform) {
|
|
48
41
|
return structuredClone(this.cached(platform));
|
|
49
42
|
}
|
|
50
43
|
save(platform, config) {
|
|
51
|
-
//
|
|
52
|
-
// it later cannot reach into the cache behind save()'s back.
|
|
44
|
+
// Cloned, so the caller's object cannot reach into the cache later.
|
|
53
45
|
this.cache.set(platform, structuredClone(config));
|
|
54
|
-
// The cache holds plaintext
|
|
55
|
-
// row is sealed.
|
|
46
|
+
// The cache holds plaintext; only the row is sealed.
|
|
56
47
|
const stored = this.secrets ? structuredClone(config) : config;
|
|
57
48
|
if (this.secrets) {
|
|
58
49
|
for (const key of ["token", "appToken"]) {
|
|
@@ -68,16 +59,9 @@ export class ChannelStore {
|
|
|
68
59
|
chat(platform, chatId) {
|
|
69
60
|
return this.get(platform).chats.find((c) => c.id === chatId);
|
|
70
61
|
}
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* them from then on — the mention and bind gates are what keep it harmless
|
|
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.
|
|
80
|
-
*/
|
|
62
|
+
/** Telegram has no "list my chats" API, so discovery is passive and happens
|
|
63
|
+
* on every message: the unchanged case must cost no clone. A new chat copies
|
|
64
|
+
* the platform defaults and owns them from then on. */
|
|
81
65
|
discoverChat(platform, chat) {
|
|
82
66
|
const cached = this.cached(platform).chats.find((c) => c.id === chat.id);
|
|
83
67
|
if (cached && cached.name === chat.name && cached.kind === chat.kind)
|
|
@@ -98,9 +82,7 @@ export class ChannelStore {
|
|
|
98
82
|
}
|
|
99
83
|
this.save(platform, config);
|
|
100
84
|
}
|
|
101
|
-
//
|
|
102
|
-
// An undiscovered chat falls back to the platform seed; in practice the
|
|
103
|
-
// adapter discovers before it asks.
|
|
85
|
+
// On the per-message path: no clone, nothing here escapes.
|
|
104
86
|
policy(platform, chatId) {
|
|
105
87
|
const config = this.cached(platform);
|
|
106
88
|
const chat = config.chats.find((c) => c.id === chatId);
|
|
@@ -119,10 +101,11 @@ export class ChannelStore {
|
|
|
119
101
|
isBound(platform, userId) {
|
|
120
102
|
return this.cached(platform).users.some((u) => u.id === userId);
|
|
121
103
|
}
|
|
122
|
-
/**
|
|
104
|
+
/** `randomInt` rejection-samples, and this code is the whole distance
|
|
105
|
+
* between a stranger and an agent with a shell. */
|
|
123
106
|
issueBindCode(platform) {
|
|
124
107
|
const config = this.get(platform);
|
|
125
|
-
const code =
|
|
108
|
+
const code = Array.from({ length: 6 }, () => BIND_CODE_ALPHABET[randomInt(BIND_CODE_ALPHABET.length)]).join("");
|
|
126
109
|
config.bindCode = { code, expiresAt: Date.now() + BIND_CODE_TTL_MS };
|
|
127
110
|
this.save(platform, config);
|
|
128
111
|
return config.bindCode;
|
|
@@ -131,15 +114,20 @@ export class ChannelStore {
|
|
|
131
114
|
const config = this.get(platform);
|
|
132
115
|
const pending = config.bindCode;
|
|
133
116
|
if (!pending || pending.expiresAt < Date.now())
|
|
134
|
-
return
|
|
135
|
-
if (pending.code !== code.trim().toUpperCase())
|
|
136
|
-
|
|
117
|
+
return "invalid";
|
|
118
|
+
if (pending.code !== code.trim().toUpperCase()) {
|
|
119
|
+
const tries = (pending.tries ?? 0) + 1;
|
|
120
|
+
const voided = tries >= BIND_CODE_TRIES;
|
|
121
|
+
config.bindCode = voided ? null : { ...pending, tries };
|
|
122
|
+
this.save(platform, config);
|
|
123
|
+
return voided ? "voided" : "invalid";
|
|
124
|
+
}
|
|
137
125
|
config.bindCode = null;
|
|
138
126
|
if (!config.users.some((u) => u.id === user.id)) {
|
|
139
127
|
config.users.push({ id: user.id, name: user.name, boundAt: Date.now() });
|
|
140
128
|
}
|
|
141
129
|
this.save(platform, config);
|
|
142
|
-
return
|
|
130
|
+
return "bound";
|
|
143
131
|
}
|
|
144
132
|
unbind(platform, userId) {
|
|
145
133
|
const config = this.get(platform);
|
|
@@ -147,20 +135,13 @@ export class ChannelStore {
|
|
|
147
135
|
this.save(platform, config);
|
|
148
136
|
}
|
|
149
137
|
}
|
|
150
|
-
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* A group denial is silent by contract: a group where the bot answers "you are
|
|
154
|
-
* not allowed" to every passing message is worse than one that stays quiet.
|
|
155
|
-
* A DM is the exception — two parties, so silence is just confusing — and the
|
|
156
|
-
* adapter answers `not-bound` there.
|
|
157
|
-
*/
|
|
138
|
+
/** The whole inbound permission policy. A group denial is silent by contract;
|
|
139
|
+
* a DM is the exception, and the adapter answers `not-bound` there. */
|
|
158
140
|
export function gate({ policy, isDm, addressed, bound, bindRequest }) {
|
|
159
141
|
if (!policy.enabled)
|
|
160
142
|
return "chat-disabled";
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// a shell. `requireMention`/`requireBind` are group settings by construction.
|
|
143
|
+
// In a DM bind is not optional: it is the only thing between a stranger and
|
|
144
|
+
// an agent with a shell. `requireMention`/`requireBind` are group settings.
|
|
164
145
|
if (isDm)
|
|
165
146
|
return bound || bindRequest ? "allow" : "not-bound";
|
|
166
147
|
if (policy.requireMention && !addressed)
|
package/dist/channels/control.js
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
// What an adapter
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// settings panel needs to read a session's model and change it, which is not a
|
|
5
|
-
// prompt and must not become a second seam. So the channel layer — which owns
|
|
6
|
-
// the router already — hands adapters this narrow, platform-blind interface.
|
|
7
|
-
// Everything here is a thin wrapper over core; no policy lives in it.
|
|
1
|
+
// What an adapter may do to a session beyond handing it a prompt. Not part of
|
|
2
|
+
// the `Channel` seam: the channel layer owns the router and hands adapters this
|
|
3
|
+
// narrow interface instead. Thin wrappers over core; no policy.
|
|
8
4
|
import { chatOf, isChannelPlatform } from "./types.js";
|
|
9
5
|
export function createControl({ router, factory, conversations, store }) {
|
|
10
6
|
const launchFor = (key) => {
|
|
11
|
-
// Decoding a conversation id back to a chat id is the channel layer's
|
|
12
|
-
// business, never core's; the chat half of every platform's id has one
|
|
13
|
-
// decoder (chatOf), so no adapter import is needed here.
|
|
14
7
|
if (!isChannelPlatform(key.channelId))
|
|
15
8
|
return {};
|
|
16
9
|
const policy = store.policy(key.channelId, chatOf(key.conversationId));
|
|
@@ -28,7 +21,6 @@ export function createControl({ router, factory, conversations, store }) {
|
|
|
28
21
|
const session = router.sessionOf(key);
|
|
29
22
|
if (!session)
|
|
30
23
|
return null;
|
|
31
|
-
// AgentSession has no cwd; the factory's listing is where it lives.
|
|
32
24
|
const summary = await factory.find(session.id);
|
|
33
25
|
const usage = session.contextUsage;
|
|
34
26
|
return {
|
|
@@ -55,8 +47,7 @@ export function createControl({ router, factory, conversations, store }) {
|
|
|
55
47
|
...launch,
|
|
56
48
|
cwd: cwd || launch.cwd || process.cwd(),
|
|
57
49
|
});
|
|
58
|
-
// Persist before attaching: a crash
|
|
59
|
-
// pointing at a session nobody recorded.
|
|
50
|
+
// Persist before attaching: a crash between must not leave an unrecorded session.
|
|
60
51
|
conversations.set(key, session.id);
|
|
61
52
|
router.attach(key, session);
|
|
62
53
|
return session.id;
|
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
// Durable conversation → session routing for IM channels
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// whose conversation id already IS a session id (web) or that persist their
|
|
5
|
-
// target themselves (tasks). An IM conversation id is a chat or a topic, so
|
|
6
|
-
// without this table a restart would silently hand every group a brand-new
|
|
7
|
-
// session: the chat history stays on screen while the agent forgets all of
|
|
8
|
-
// it, and the old transcript becomes unreachable.
|
|
9
|
-
//
|
|
10
|
-
// Owned by channels/ rather than core/ so core stays storage-agnostic — the
|
|
11
|
-
// same split tasks/ already uses for its target session ids.
|
|
1
|
+
// Durable conversation → session routing for IM channels: an IM conversation id
|
|
2
|
+
// is a chat, not a session id, so without this table a restart would hand every
|
|
3
|
+
// group a brand-new session. In channels/ so core stays storage-agnostic.
|
|
12
4
|
import { pierDb } from "../db.js";
|
|
13
5
|
export class ConversationStore {
|
|
14
6
|
db;
|
|
@@ -29,30 +21,22 @@ export class ConversationStore {
|
|
|
29
21
|
session_id = excluded.session_id, updated_at = excluded.updated_at
|
|
30
22
|
`).run(key.channelId, key.conversationId, sessionId, Date.now());
|
|
31
23
|
}
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
* a surface asking "was this turn already delivered to a chat?" long after
|
|
35
|
-
* the turn cannot use it. Sessions with no row are nobody's conversation. */
|
|
24
|
+
/** Durable, unlike the router's answer, which is gone once an idle session
|
|
25
|
+
* is evicted. No row: nobody's conversation. */
|
|
36
26
|
channelOf(sessionId) {
|
|
37
27
|
const row = this.db.prepare(`
|
|
38
28
|
SELECT channel_id FROM conversations WHERE session_id = ? LIMIT 1
|
|
39
29
|
`).get(sessionId);
|
|
40
30
|
return row?.channel_id;
|
|
41
31
|
}
|
|
42
|
-
/** Drop a mapping whose session Pi no longer has, so the next message
|
|
43
|
-
* starts a fresh one instead of failing forever. */
|
|
44
32
|
forget(key) {
|
|
45
33
|
this.db.prepare(`
|
|
46
34
|
DELETE FROM conversations WHERE channel_id = ? AND conversation_id = ?
|
|
47
35
|
`).run(key.channelId, key.conversationId);
|
|
48
36
|
}
|
|
49
37
|
}
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
* session across restarts, and only create when there is nothing to resume.
|
|
53
|
-
* Wired in main.ts, so neither core nor an adapter learns where the mapping
|
|
54
|
-
* lives.
|
|
55
|
-
*/
|
|
38
|
+
/** The IM half of the router's session factory, wired in main.ts so neither
|
|
39
|
+
* core nor an adapter learns where the mapping lives. */
|
|
56
40
|
export function resolveConversation(store, factory, launchFor, onStale) {
|
|
57
41
|
return async (key) => {
|
|
58
42
|
const known = store.get(key);
|
|
@@ -61,8 +45,7 @@ export function resolveConversation(store, factory, launchFor, onStale) {
|
|
|
61
45
|
return await factory.resume(known);
|
|
62
46
|
}
|
|
63
47
|
catch (err) {
|
|
64
|
-
//
|
|
65
|
-
// transcript was deleted. Re-route rather than fail every message.
|
|
48
|
+
// Never persisted, or deleted: re-route rather than fail every message.
|
|
66
49
|
onStale?.(`${key.channelId}:${key.conversationId} lost session ${known}: ${String(err)}`);
|
|
67
50
|
store.forget(key);
|
|
68
51
|
}
|