@timqi/pier 0.0.1 ā 0.0.3
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 +87 -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-tool.js +28 -3
- 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 +200 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +180 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +192 -18
- package/dist/settings.js +77 -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 +92 -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 +146 -0
- package/dist/web/auth.js +89 -26
- package/dist/web/explorer.js +147 -0
- package/dist/web/files.js +28 -12
- package/dist/web/instance.js +165 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +141 -0
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -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 +50 -32
- package/dist/web/server.js +110 -120
- package/docs/deploy.md +142 -64
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +20 -3
- 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
package/dist/agent/models.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
// Pure model-list curation, unit-testable without Pi. The raw registry
|
|
2
2
|
// returns every catalog entry per authed provider (legacy generations, dated
|
|
3
3
|
// aliases, -latest aliases); the picker wants the common working set.
|
|
4
|
+
/** Operator-pinned models first, menu order, without inventing entries: a
|
|
5
|
+
* pin that is not in the list (stale menu, logged-out provider) is skipped
|
|
6
|
+
* rather than offered as a model that would only fail on selection. */
|
|
7
|
+
export function pinFirst(models, pins) {
|
|
8
|
+
if (!pins.length)
|
|
9
|
+
return models;
|
|
10
|
+
const key = (m) => `${m.provider}/${m.id}`;
|
|
11
|
+
const listed = new Map(models.map((m) => [key(m), m]));
|
|
12
|
+
const pinned = pins.map((p) => listed.get(key(p))).filter((m) => !!m);
|
|
13
|
+
const picked = new Set(pinned.map(key));
|
|
14
|
+
return [...pinned, ...models.filter((m) => !picked.has(key(m)))];
|
|
15
|
+
}
|
|
4
16
|
export function curateModels(models) {
|
|
5
17
|
const ids = new Set(models.map((m) => `${m.provider}/${m.id}`));
|
|
6
18
|
return models
|
package/dist/agent/pi.js
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
// The ONLY file allowed to import @earendil-works/pi-*. Implements the
|
|
2
2
|
// AgentFactory/AgentSession seam from src/core/types.ts on the Pi SDK.
|
|
3
3
|
// No Pi type may appear in an exported signature.
|
|
4
|
-
import { createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { logger } from "../log.js";
|
|
6
|
-
import {
|
|
7
|
-
import { defaultAgentDir } from "./config.js";
|
|
8
|
-
import { curateModels } from "./models.js";
|
|
6
|
+
import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
7
|
+
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
8
|
+
import { curateModels, pinFirst } from "./models.js";
|
|
9
9
|
const log = logger("agent");
|
|
10
|
-
const toImageContent = (images) => images?.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
|
|
11
10
|
/** Pi's bash tool has no default timeout, so a hung command holds the turn
|
|
12
11
|
* until someone aborts it ā nobody is watching in a scheduled task. Kept below
|
|
13
|
-
* the task-run timeout
|
|
12
|
+
* the default task-run timeout so a stuck command comes back as
|
|
14
13
|
* a tool error the agent can retry with an explicit longer timeout, instead of
|
|
15
14
|
* killing the whole run. */
|
|
16
15
|
const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
16
|
+
/** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
|
|
17
|
+
const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care ā do the work, verify results, and state what you could not check.
|
|
18
|
+
|
|
19
|
+
# Communication
|
|
20
|
+
These rules govern conversational replies. When the reply *is* the deliverable ā a report that was asked for, a review, a task run whose result another agent reads ā the work sets the length: complete beats brief, and nothing below caps it.
|
|
21
|
+
- Answer with the conclusion only. Reasons, process, trade-offs, alternatives: only when asked.
|
|
22
|
+
- Cap per reply: 100 words (or 100 Chinese chars), max 3 bullets; 300 when explicitly asked why or how. Code blocks, diffs and commands don't count.
|
|
23
|
+
- Never: preamble, restating the question, closing summaries, "I'm going to..." narration, listing changes already visible in the diff.
|
|
24
|
+
- After edits, say only: file(s) touched + one line on the result. Don't explain self-evident code.
|
|
25
|
+
- Show file paths as \`path:line\`.
|
|
26
|
+
- If the honest answer needs more than the cap, give the conclusion plus one short "want the details?" ā don't dump it.
|
|
27
|
+
- Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.`;
|
|
28
|
+
export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
|
|
17
29
|
/** Patching the call is cheaper than replacing the tool: the built-in keeps its
|
|
18
30
|
* shell settings, and the agent spends no tokens deciding a timeout. */
|
|
19
31
|
const bashTimeoutDefault = (pi) => {
|
|
@@ -23,10 +35,24 @@ const bashTimeoutDefault = (pi) => {
|
|
|
23
35
|
}
|
|
24
36
|
});
|
|
25
37
|
};
|
|
26
|
-
class PiSession {
|
|
38
|
+
export class PiSession {
|
|
27
39
|
pi;
|
|
28
|
-
|
|
40
|
+
pinned;
|
|
41
|
+
constructor(pi,
|
|
42
|
+
/** Operator pins, read per call ā the menu can change while we run. */
|
|
43
|
+
pinned = () => []) {
|
|
29
44
|
this.pi = pi;
|
|
45
|
+
this.pinned = pinned;
|
|
46
|
+
}
|
|
47
|
+
/** Pi's dispose unhooks the one listener that persists and emits, so a turn
|
|
48
|
+
* started after it runs for real ā model call, tools and all ā and lands
|
|
49
|
+
* nowhere: no transcript entry, no event, and a promise that resolves as if
|
|
50
|
+
* it worked. Refusing is what turns that silence into the failure it is;
|
|
51
|
+
* callers already report or retry a rejection (§5b). */
|
|
52
|
+
disposed = false;
|
|
53
|
+
live() {
|
|
54
|
+
if (this.disposed)
|
|
55
|
+
throw new Error(`session ${this.pi.sessionId} is closed`);
|
|
30
56
|
}
|
|
31
57
|
get id() {
|
|
32
58
|
return this.pi.sessionId;
|
|
@@ -57,7 +83,7 @@ class PiSession {
|
|
|
57
83
|
}
|
|
58
84
|
async availableModels() {
|
|
59
85
|
const available = await this.pi.modelRuntime.getAvailable();
|
|
60
|
-
const curated = curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning })));
|
|
86
|
+
const curated = pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
|
|
61
87
|
// The session's active model must stay selectable even when curation
|
|
62
88
|
// (or an older catalog) would hide it.
|
|
63
89
|
const current = this.model;
|
|
@@ -82,11 +108,9 @@ class PiSession {
|
|
|
82
108
|
return this.pi.clearQueue();
|
|
83
109
|
}
|
|
84
110
|
async history() {
|
|
111
|
+
this.live();
|
|
85
112
|
return toChatTurns(this.pi.messages);
|
|
86
113
|
}
|
|
87
|
-
async image(ordinal) {
|
|
88
|
-
return imageAt(this.pi.messages, ordinal);
|
|
89
|
-
}
|
|
90
114
|
async rewindToUserTurn(index) {
|
|
91
115
|
const total = (await this.history()).filter((t) => t.role === "user").length;
|
|
92
116
|
// Anchor at the tail: branch entries keep compacted-away history that
|
|
@@ -104,16 +128,22 @@ class PiSession {
|
|
|
104
128
|
if (cancelled)
|
|
105
129
|
throw new Error("rewind cancelled");
|
|
106
130
|
}
|
|
107
|
-
|
|
108
|
-
|
|
131
|
+
// Async, so a refusal is a rejected promise: the seam promises callers they
|
|
132
|
+
// may only `.catch()` (core/types.ts), and dispatch does exactly that.
|
|
133
|
+
async prompt(text) {
|
|
134
|
+
this.live();
|
|
135
|
+
return this.pi.prompt(text);
|
|
109
136
|
}
|
|
110
|
-
steer(text
|
|
111
|
-
|
|
137
|
+
async steer(text) {
|
|
138
|
+
this.live();
|
|
139
|
+
return this.pi.steer(text);
|
|
112
140
|
}
|
|
113
|
-
followUp(text
|
|
114
|
-
|
|
141
|
+
async followUp(text) {
|
|
142
|
+
this.live();
|
|
143
|
+
return this.pi.followUp(text);
|
|
115
144
|
}
|
|
116
|
-
systemInput(text, origin, mode) {
|
|
145
|
+
async systemInput(text, origin, mode) {
|
|
146
|
+
this.live();
|
|
117
147
|
return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
|
|
118
148
|
}
|
|
119
149
|
abort() {
|
|
@@ -136,6 +166,7 @@ class PiSession {
|
|
|
136
166
|
return undefined;
|
|
137
167
|
}
|
|
138
168
|
async dispose() {
|
|
169
|
+
this.disposed = true;
|
|
139
170
|
this.pi.dispose();
|
|
140
171
|
}
|
|
141
172
|
}
|
|
@@ -144,6 +175,8 @@ export class PiAgentFactory {
|
|
|
144
175
|
instructions;
|
|
145
176
|
skillPaths;
|
|
146
177
|
credentials;
|
|
178
|
+
providerConfig;
|
|
179
|
+
pinned;
|
|
147
180
|
constructor(extraTools = [],
|
|
148
181
|
/** Appended as a virtual context file, so Pi's own prompt stays intact.
|
|
149
182
|
* Read per session, not captured once: it carries settings a user can
|
|
@@ -157,23 +190,141 @@ export class PiAgentFactory {
|
|
|
157
190
|
* Optional only for bare test factories; main.ts always passes one, and
|
|
158
191
|
* every runtime built here reads and writes through it ā an OAuth refresh
|
|
159
192
|
* persists to the database, never to a plaintext file. */
|
|
160
|
-
credentials)
|
|
193
|
+
credentials, providerConfig = new PiConfigStore(),
|
|
194
|
+
/** Operator-pinned models (Console ā Settings ā Models), surfaced first in
|
|
195
|
+
* every picker. A getter for the same reason `instructions` is one. */
|
|
196
|
+
pinned = () => []) {
|
|
161
197
|
this.extraTools = extraTools;
|
|
162
198
|
this.instructions = instructions;
|
|
163
199
|
this.skillPaths = skillPaths;
|
|
164
200
|
this.credentials = credentials;
|
|
201
|
+
this.providerConfig = providerConfig;
|
|
202
|
+
this.pinned = pinned;
|
|
165
203
|
}
|
|
166
204
|
/** One runtime for the whole process; catalogs are global, not per session. */
|
|
167
205
|
catalog;
|
|
206
|
+
refreshQueue = Promise.resolve();
|
|
207
|
+
builtinProviderIds;
|
|
168
208
|
/** Structural fit: CredentialStore mirrors pi-ai's interface of the same
|
|
169
209
|
* name, so the SDK accepts it without this file exporting any SDK type. */
|
|
170
210
|
createRuntime() {
|
|
171
211
|
return ModelRuntime.create(this.credentials ? { credentials: this.credentials } : {});
|
|
172
212
|
}
|
|
213
|
+
authRuntime() {
|
|
214
|
+
return (this.catalog ??= this.createRuntime());
|
|
215
|
+
}
|
|
216
|
+
refreshedRuntime() {
|
|
217
|
+
const result = this.refreshQueue.then(async () => {
|
|
218
|
+
const runtime = await this.authRuntime();
|
|
219
|
+
await runtime.refresh({ allowNetwork: false });
|
|
220
|
+
return runtime;
|
|
221
|
+
});
|
|
222
|
+
this.refreshQueue = result.then(() => undefined, () => undefined);
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
builtinIds() {
|
|
226
|
+
return (this.builtinProviderIds ??= ModelRuntime.create({
|
|
227
|
+
...(this.credentials ? { credentials: this.credentials } : {}),
|
|
228
|
+
modelsPath: null,
|
|
229
|
+
refreshOnCreate: false,
|
|
230
|
+
}).then((runtime) => new Set(runtime.getProviders().map((provider) => provider.id))));
|
|
231
|
+
}
|
|
173
232
|
async availableModels() {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
233
|
+
const available = await (await this.refreshedRuntime()).getAvailable();
|
|
234
|
+
return pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
|
|
235
|
+
}
|
|
236
|
+
async providers() {
|
|
237
|
+
const [builtinIds, structures] = await Promise.all([
|
|
238
|
+
this.builtinIds(),
|
|
239
|
+
this.providerConfig.providerStructures(),
|
|
240
|
+
]);
|
|
241
|
+
// Refresh after queued config writes settle, so runtime and structure never
|
|
242
|
+
// combine an older catalog with a newer models.json snapshot.
|
|
243
|
+
const runtime = await this.refreshedRuntime();
|
|
244
|
+
const stored = new Map((await runtime.listCredentials()).map((c) => [c.providerId, c.type]));
|
|
245
|
+
return runtime.getProviders().map((provider) => {
|
|
246
|
+
const status = runtime.getProviderAuthStatus(provider.id);
|
|
247
|
+
const methods = [];
|
|
248
|
+
if (provider.auth.apiKey?.login) {
|
|
249
|
+
methods.push({ type: "api_key", name: provider.auth.apiKey.name });
|
|
250
|
+
}
|
|
251
|
+
if (provider.auth.oauth) {
|
|
252
|
+
methods.push({
|
|
253
|
+
type: "oauth",
|
|
254
|
+
name: provider.auth.oauth.loginLabel ?? provider.auth.oauth.name,
|
|
255
|
+
...(provider.auth.oauth.isSubscription ? { subscription: true } : {}),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const credential = stored.get(provider.id);
|
|
259
|
+
const structure = structures[provider.id];
|
|
260
|
+
return {
|
|
261
|
+
id: provider.id,
|
|
262
|
+
name: structure?.name ?? provider.name,
|
|
263
|
+
builtin: builtinIds.has(provider.id),
|
|
264
|
+
methods,
|
|
265
|
+
configured: status.configured,
|
|
266
|
+
...(status.label ? { source: status.label } : status.source ? { source: status.source } : {}),
|
|
267
|
+
...(credential ? { stored: credential } : {}),
|
|
268
|
+
...(structure?.endpoint ? { endpoint: structure.endpoint } : {}),
|
|
269
|
+
...(structure?.api ? { api: structure.api } : {}),
|
|
270
|
+
...(structure?.models ? { models: structure.models } : {}),
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async setup(input) {
|
|
275
|
+
const builtins = await this.builtinIds();
|
|
276
|
+
if (input.kind === "builtin" && !builtins.has(input.id)) {
|
|
277
|
+
throw new Error(`not a built-in provider: ${input.id}`);
|
|
278
|
+
}
|
|
279
|
+
if (input.kind === "custom" && builtins.has(input.id)) {
|
|
280
|
+
throw new Error(`built-in provider must use built-in setup: ${input.id}`);
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await this.providerConfig.setupProvider(input, async () => {
|
|
284
|
+
const runtime = await this.refreshedRuntime();
|
|
285
|
+
const error = runtime.getError();
|
|
286
|
+
if (error)
|
|
287
|
+
throw new Error(error);
|
|
288
|
+
if (!runtime.getProvider(input.id))
|
|
289
|
+
throw new Error(`provider did not load: ${input.id}`);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
try {
|
|
294
|
+
await this.refreshedRuntime();
|
|
295
|
+
}
|
|
296
|
+
catch (refreshErr) {
|
|
297
|
+
log.warn("provider runtime refresh failed after config rollback", refreshErr);
|
|
298
|
+
}
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async login(providerId, type, interaction) {
|
|
303
|
+
const store = this.credentials;
|
|
304
|
+
const previous = await store?.read(providerId);
|
|
305
|
+
const restore = async (committed) => {
|
|
306
|
+
if (store && await store.replaceIfCurrent(providerId, committed, previous)) {
|
|
307
|
+
await this.refreshedRuntime();
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
const committed = await (await this.authRuntime()).login(providerId, type, interaction);
|
|
312
|
+
return () => restore(committed);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
if (err instanceof CredentialSynchronizationError && err.credential) {
|
|
316
|
+
try {
|
|
317
|
+
await restore(err.credential);
|
|
318
|
+
}
|
|
319
|
+
catch (rollback) {
|
|
320
|
+
throw new AggregateError([err, rollback], `failed to restore credential for ${providerId}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async logout(providerId) {
|
|
327
|
+
await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
|
|
177
328
|
}
|
|
178
329
|
/**
|
|
179
330
|
* Pi discovers AGENTS.md itself; we append one more, in memory, telling the
|
|
@@ -184,6 +335,9 @@ export class PiAgentFactory {
|
|
|
184
335
|
const loader = new DefaultResourceLoader({
|
|
185
336
|
cwd,
|
|
186
337
|
agentDir: defaultAgentDir(), // same discovery Pi would have done itself
|
|
338
|
+
// SYSTEM.md remains user-owned: append it after Pier's replacement for
|
|
339
|
+
// Pi's generic default, preserving the user's later instruction layer.
|
|
340
|
+
systemPromptOverride: pierSystemPrompt,
|
|
187
341
|
additionalSkillPaths: this.skillPaths,
|
|
188
342
|
extensionFactories: [{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true }],
|
|
189
343
|
agentsFilesOverride: (current) => {
|
|
@@ -230,11 +384,12 @@ export class PiAgentFactory {
|
|
|
230
384
|
}
|
|
231
385
|
},
|
|
232
386
|
}));
|
|
233
|
-
if (opts.name)
|
|
234
|
-
sessionManager.appendSessionInfo(opts.name);
|
|
235
387
|
// Locked Secrets is a refusal with a reason, here ā not "provider is not
|
|
236
|
-
// configured" three calls later, and never a fall back to auth.json.
|
|
388
|
+
// configured" three calls later, and never a fall back to auth.json. Before
|
|
389
|
+
// appendSessionInfo, so the refused open writes nothing to disk.
|
|
237
390
|
this.credentials?.assertUnlocked();
|
|
391
|
+
if (opts.name)
|
|
392
|
+
sessionManager.appendSessionInfo(opts.name);
|
|
238
393
|
const tools = opts.capabilities === "read"
|
|
239
394
|
? ["read", "grep", "find", "ls", ...this.extraTools.map((tool) => tool.name)]
|
|
240
395
|
: undefined;
|
|
@@ -247,7 +402,7 @@ export class PiAgentFactory {
|
|
|
247
402
|
resourceLoader: await this.resourceLoader(cwd),
|
|
248
403
|
});
|
|
249
404
|
live = created.session;
|
|
250
|
-
const session = new PiSession(live);
|
|
405
|
+
const session = new PiSession(live, this.pinned);
|
|
251
406
|
if (opts.model)
|
|
252
407
|
await session.setModel(opts.model);
|
|
253
408
|
if (opts.thinking)
|
package/dist/boards/boards.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Only <board>/site is reachable over HTTP: sources, README and the manifest
|
|
6
6
|
// itself stay off the wire, so a public board leaks nothing about how it was
|
|
7
|
-
// made.
|
|
8
|
-
//
|
|
7
|
+
// made. `/boards/*` is authenticated; `/p/*` additionally requires the
|
|
8
|
+
// manifest's `public` flag and runs as sandboxed active content.
|
|
9
9
|
import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
10
10
|
import { extname, join, resolve, sep } from "node:path";
|
|
11
11
|
import { logger } from "../log.js";
|
|
@@ -33,11 +33,16 @@ const TYPES = {
|
|
|
33
33
|
".txt": "text/plain; charset=utf-8",
|
|
34
34
|
".csv": "text/plain; charset=utf-8",
|
|
35
35
|
};
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
36
|
+
// Board HTML is active content on the workbench origin. Scripts stay available
|
|
37
|
+
// for self-contained pages, but the response sandbox removes forms, frames,
|
|
38
|
+
// popups and network access. Public pages omit same-origin too, so their scripts
|
|
39
|
+
// receive an opaque origin and cannot inherit an operator's authority.
|
|
39
40
|
const CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " +
|
|
40
|
-
"script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-
|
|
41
|
+
"script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; " +
|
|
42
|
+
"worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; " +
|
|
43
|
+
"frame-ancestors 'none'";
|
|
44
|
+
const PRIVATE_CSP = `sandbox allow-scripts allow-same-origin; ${CSP}`;
|
|
45
|
+
const PUBLIC_CSP = `sandbox allow-scripts; ${CSP}`;
|
|
41
46
|
/** Malformed boards are reported once, not on every scan. */
|
|
42
47
|
const warned = new Set();
|
|
43
48
|
/** The one choke point where a slug becomes a path, so the slug is validated
|
|
@@ -148,12 +153,17 @@ async function serveFile(c, dir, slug, rest, publicOnly) {
|
|
|
148
153
|
const headers = {
|
|
149
154
|
"content-type": type,
|
|
150
155
|
"x-content-type-options": "nosniff",
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
"cache-control": type.startsWith("text/html")
|
|
156
|
+
// Every public request rechecks the manifest, so unpublishing cannot leave
|
|
157
|
+
// a shared-cache copy reachable. Private assets may stay in one browser.
|
|
158
|
+
"cache-control": publicOnly || type.startsWith("text/html")
|
|
159
|
+
? "no-store"
|
|
160
|
+
: "private, max-age=300",
|
|
161
|
+
"content-security-policy": publicOnly ? PUBLIC_CSP : PRIVATE_CSP,
|
|
154
162
|
};
|
|
163
|
+
// Public sandboxed pages have an opaque origin. Fonts and module scripts need
|
|
164
|
+
// CORS even when their URLs are under the same published board.
|
|
155
165
|
if (publicOnly)
|
|
156
|
-
headers["
|
|
166
|
+
headers["access-control-allow-origin"] = "*";
|
|
157
167
|
return c.body(await readFile(file), 200, headers);
|
|
158
168
|
}
|
|
159
169
|
export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
package/dist/channels/routes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Settings ā Channels HTTP surface. One document per platform: credentials,
|
|
2
2
|
// global defaults, bound users and discovered chats travel together, so the
|
|
3
3
|
// UI never has to stitch two half-configs.
|
|
4
4
|
import { isThinkingLevel } from "../core/types.js";
|
package/dist/channels/runtime.js
CHANGED
|
@@ -12,22 +12,39 @@ const ADAPTERS = [
|
|
|
12
12
|
// Lifecycle news, which is not what the injected sink below is for: that one
|
|
13
13
|
// is a warning sink the adapters share, and "slack started" is not a warning.
|
|
14
14
|
const log = logger("channels");
|
|
15
|
+
// The parameter below shadows `log` inside its own default expression.
|
|
16
|
+
const warn = (m) => log.warn(m);
|
|
15
17
|
export class ChannelRuntime {
|
|
16
18
|
store;
|
|
17
19
|
router;
|
|
18
20
|
control;
|
|
19
21
|
log;
|
|
20
22
|
live = new Map();
|
|
21
|
-
|
|
23
|
+
reloading = Promise.resolve();
|
|
24
|
+
stopped = false;
|
|
25
|
+
constructor(store, router, control, log = warn) {
|
|
22
26
|
this.store = store;
|
|
23
27
|
this.router = router;
|
|
24
28
|
this.control = control;
|
|
25
29
|
this.log = log;
|
|
26
30
|
}
|
|
27
|
-
/** (Re)start every platform whose config says it should run. Idempotent.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
/** (Re)start every platform whose config says it should run. Idempotent.
|
|
32
|
+
* Serialized ā two concurrent Console saves raced into duplicate live
|
|
33
|
+
* adapters; platforms restart in parallel so a hung start on one cannot
|
|
34
|
+
* stall the other, and a failure is logged, never swallowed. */
|
|
35
|
+
reload() {
|
|
36
|
+
const run = this.reloading.catch(() => { }).then(async () => {
|
|
37
|
+
if (this.stopped)
|
|
38
|
+
return;
|
|
39
|
+
const results = await Promise.allSettled(ADAPTERS.map((a) => this.restart(a)));
|
|
40
|
+
results.forEach((result, i) => {
|
|
41
|
+
if (result.status === "rejected") {
|
|
42
|
+
this.log(`${ADAPTERS[i].platform} reload failed: ${String(result.reason)}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
this.reloading = run;
|
|
47
|
+
return run;
|
|
31
48
|
}
|
|
32
49
|
async restart(adapter) {
|
|
33
50
|
const { platform, needsAppToken, build } = adapter;
|
|
@@ -67,7 +84,21 @@ export class ChannelRuntime {
|
|
|
67
84
|
this.live.set(platform, channel);
|
|
68
85
|
log.info(`${platform} started`);
|
|
69
86
|
}
|
|
87
|
+
/** Post a note into a conversation on a live adapter. Boot-time restart-note
|
|
88
|
+
* delivery (src/drain.ts) has no session to report through; false means the
|
|
89
|
+
* platform is not running, so the caller can say so instead of dropping it. */
|
|
90
|
+
async notify(platform, conversationId, text) {
|
|
91
|
+
const channel = this.live.get(platform);
|
|
92
|
+
if (!channel)
|
|
93
|
+
return false;
|
|
94
|
+
await channel.notify(conversationId, { text, origin: { kind: "error" } });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
70
97
|
async stop() {
|
|
98
|
+
// Join the reload queue first: an in-flight restart could otherwise
|
|
99
|
+
// register an adapter after `live` was cleared ā running, unstoppable.
|
|
100
|
+
this.stopped = true;
|
|
101
|
+
await this.reloading.catch(() => { });
|
|
71
102
|
for (const channel of this.live.values()) {
|
|
72
103
|
await channel.stop().catch((err) => this.log(`${channel.id} did not stop cleanly: ${String(err)}`));
|
|
73
104
|
}
|
|
@@ -288,9 +288,7 @@ export class SlackApi {
|
|
|
288
288
|
});
|
|
289
289
|
if (!res.ok)
|
|
290
290
|
throw new Error(`slack file download: ${res.status}`);
|
|
291
|
-
const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "
|
|
292
|
-
|
|
293
|
-
throw new Error(`slack file is ${mimeType}, not an image`);
|
|
294
|
-
return { data: Buffer.from(await res.arrayBuffer()).toString("base64"), mimeType };
|
|
291
|
+
const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "application/octet-stream";
|
|
292
|
+
return { bytes: new Uint8Array(await res.arrayBuffer()), mimeType };
|
|
295
293
|
}
|
|
296
294
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// inbound traffic: which renderer to use, how to chunk against that renderer's
|
|
5
5
|
// limit, and what an empty turn still has to say. The adapter keeps the š
|
|
6
6
|
// receipts, because those are about the turn ending, not about what was said.
|
|
7
|
-
import { formatTurnMeta, originLabel } from "../core/reply.js";
|
|
7
|
+
import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
|
|
8
8
|
import { isBlockRejection } from "./slack-api.js";
|
|
9
9
|
import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
|
|
10
10
|
/**
|
|
@@ -38,13 +38,9 @@ export class SlackOutbound {
|
|
|
38
38
|
// indistinguishable from a crash, a dropped connection or a bug, and the
|
|
39
39
|
// person waiting has no way to tell. A muted one-liner is the cheapest
|
|
40
40
|
// honest answer.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
? ""
|
|
45
|
-
: reply.silence
|
|
46
|
-
? `_stayed silent ā ${escapeMrkdwn(reply.silence)}_`
|
|
47
|
-
: "_no reply_";
|
|
41
|
+
const quiet = isSilentReply(reply)
|
|
42
|
+
? `_${quietLabel(reply.silence && escapeMrkdwn(reply.silence))}_`
|
|
43
|
+
: "";
|
|
48
44
|
if (!(text || row || footer || quiet))
|
|
49
45
|
return;
|
|
50
46
|
const parts = text ? chunk(text, this.budget()) : [""];
|
|
@@ -20,9 +20,6 @@ export const MARKDOWN_MAX = 11_000;
|
|
|
20
20
|
export const MRKDWN_MAX = 2800;
|
|
21
21
|
// Slack truncates a button label past this, mid-word.
|
|
22
22
|
const BUTTON_MAX = 75;
|
|
23
|
-
// Slack's cap on one actions block. `MAX_SUGGESTIONS` in core/reply.ts already
|
|
24
|
-
// holds the agent to 5, so this only guards a caller that bypasses it.
|
|
25
|
-
const MAX_BUTTONS = 25;
|
|
26
23
|
/** Shared: the adapter and the panel escape plain text with this too. */
|
|
27
24
|
export const escapeMrkdwn = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
28
25
|
/**
|
|
@@ -187,7 +184,7 @@ const truncate = (label) => label.length > BUTTON_MAX ? `${label.slice(0, BUTTON
|
|
|
187
184
|
export function actions(labels) {
|
|
188
185
|
if (!labels.length)
|
|
189
186
|
return undefined;
|
|
190
|
-
const elements = labels.
|
|
187
|
+
const elements = labels.map((label, index) => ({
|
|
191
188
|
type: "button",
|
|
192
189
|
action_id: `${OFFER_PREFIX}${index}`,
|
|
193
190
|
text: { type: "plain_text", text: truncate(label), emoji: true },
|
|
@@ -45,12 +45,20 @@ export function slackToolSpec(execute) {
|
|
|
45
45
|
return {
|
|
46
46
|
name: "slack",
|
|
47
47
|
label: "Slack",
|
|
48
|
-
description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls ā write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax ā read the pier-slack skill before posting.",
|
|
48
|
+
description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; delete removes the message at a ts, which Slack allows only for messages Pier itself posted; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls ā write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax ā read the pier-slack skill before posting.",
|
|
49
49
|
parameters: Type.Object({
|
|
50
50
|
// A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
|
|
51
51
|
operation: Type.Unsafe({
|
|
52
52
|
type: "string",
|
|
53
|
-
enum: [
|
|
53
|
+
enum: [
|
|
54
|
+
"context",
|
|
55
|
+
"read_channel",
|
|
56
|
+
"read_thread",
|
|
57
|
+
"read_message",
|
|
58
|
+
"post",
|
|
59
|
+
"delete",
|
|
60
|
+
"channels",
|
|
61
|
+
],
|
|
54
62
|
}),
|
|
55
63
|
/**
|
|
56
64
|
* Channel id (`Cā¦`/`Dā¦`/`Gā¦`) or the `#name` shown by `channels`. Omit to
|
|
@@ -61,7 +69,7 @@ export function slackToolSpec(execute) {
|
|
|
61
69
|
until: Type.Optional(Type.String()),
|
|
62
70
|
/** Strictly newer than this ā "what changed since I last looked". */
|
|
63
71
|
after: Type.Optional(Type.String()),
|
|
64
|
-
/** The one message `read_message` is about. */
|
|
72
|
+
/** The one message `read_message` or `delete` is about. */
|
|
65
73
|
ts: Type.Optional(Type.String()),
|
|
66
74
|
limit: Type.Optional(Type.Number()),
|
|
67
75
|
thread_ts: Type.Optional(Type.String()),
|
|
@@ -174,6 +182,21 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
174
182
|
threadTs: threadTs ?? sent.ts,
|
|
175
183
|
};
|
|
176
184
|
}
|
|
185
|
+
if (input.operation === "delete") {
|
|
186
|
+
// Never defaulted from `here`: the thread's ts is the parent message, and
|
|
187
|
+
// "delete" with an implied target is the one mistake with no undo.
|
|
188
|
+
const ts = required(input.ts, "ts");
|
|
189
|
+
try {
|
|
190
|
+
await client.deleteMessage(channel, ts);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
throw new Error(explain(err));
|
|
194
|
+
}
|
|
195
|
+
// A removal leaves nothing behind to read, so the log is the only record
|
|
196
|
+
// that it happened at all.
|
|
197
|
+
deps.log(`slack tool deleted ${ts} in ${channel}`);
|
|
198
|
+
return { channel, ts, deleted: true };
|
|
199
|
+
}
|
|
177
200
|
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
178
201
|
}
|
|
179
202
|
/** Accept a `#name` or a bare name as well as an id ā models prefer names. */
|
|
@@ -285,6 +308,8 @@ function explain(err) {
|
|
|
285
308
|
missing_scope: "Pier's Slack app lacks the scope for this read; the operator must reinstall it",
|
|
286
309
|
ratelimited: "Slack rate-limited this read; wait a minute or ask for a narrower range",
|
|
287
310
|
thread_not_found: "no thread with that ts in this channel",
|
|
311
|
+
cant_delete_message: "Slack only lets Pier delete what its own bot posted; a person's message has to be deleted by them",
|
|
312
|
+
message_not_found: "no message with that ts in this channel ā a ts only means anything in the conversation it came from",
|
|
288
313
|
}[code] ?? String(err);
|
|
289
314
|
}
|
|
290
315
|
/** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
|