@timqi/pier 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
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 },
|
package/dist/channels/slack.js
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
//
|
|
23
23
|
// Everything policy-shaped (mention/bind gates, per-chat overrides) is in
|
|
24
24
|
// config.ts, platform-blind and shared with Telegram.
|
|
25
|
+
import { saveInbound } from "../core/inbox.js";
|
|
26
|
+
import { fileMarker, lostMarker, MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
25
27
|
import { logger } from "../log.js";
|
|
26
28
|
import { Chains } from "./chains.js";
|
|
27
29
|
import { parseCommand } from "./commands.js";
|
|
@@ -178,7 +180,7 @@ export class SlackChannel {
|
|
|
178
180
|
}
|
|
179
181
|
async stop() {
|
|
180
182
|
this.running = false;
|
|
181
|
-
await this.socket?.close().catch(() => {
|
|
183
|
+
await this.socket?.close().catch((err) => this.log(`slack socket did not close cleanly: ${String(err)}`));
|
|
182
184
|
this.socket = undefined;
|
|
183
185
|
await this.chains.drain(DRAIN_TIMEOUT_MS);
|
|
184
186
|
}
|
|
@@ -264,7 +266,7 @@ export class SlackChannel {
|
|
|
264
266
|
if (!ts)
|
|
265
267
|
return this.log("message event without a ts, dropped");
|
|
266
268
|
const raw = (event.text ?? "").trim();
|
|
267
|
-
const files =
|
|
269
|
+
const files = event.files ?? [];
|
|
268
270
|
if (!raw && !files.length)
|
|
269
271
|
return;
|
|
270
272
|
const { kind } = await this.directory.channel(this.api, channel, event);
|
|
@@ -301,7 +303,7 @@ export class SlackChannel {
|
|
|
301
303
|
}
|
|
302
304
|
// Downloading only past the gate: an unauthorized sender must not be able
|
|
303
305
|
// to make the bot pull bytes on their behalf.
|
|
304
|
-
const
|
|
306
|
+
const markers = await this.saveAttachments(files);
|
|
305
307
|
this.receipts.mark(here.conversationId, channel, ts);
|
|
306
308
|
// IM messages steer by default: a follow-up that waits for the turn to end
|
|
307
309
|
// is the wrong default when the human is watching a 👀 in a thread.
|
|
@@ -311,8 +313,7 @@ export class SlackChannel {
|
|
|
311
313
|
// A Slack thread is many people talking into one session, so the agent is
|
|
312
314
|
// told who spoke — and the id, which is what a mention needs.
|
|
313
315
|
sender: { id: event.user, name: await this.directory.user(this.api, event.user) },
|
|
314
|
-
text,
|
|
315
|
-
images,
|
|
316
|
+
text: [text, ...markers].filter(Boolean).join("\n"),
|
|
316
317
|
mode: "steer",
|
|
317
318
|
});
|
|
318
319
|
}
|
|
@@ -458,17 +459,27 @@ export class SlackChannel {
|
|
|
458
459
|
}
|
|
459
460
|
return name ?? channel;
|
|
460
461
|
}
|
|
461
|
-
|
|
462
|
-
|
|
462
|
+
/** Save uploads to the inbox; each becomes a prompt line — a failed one
|
|
463
|
+
* becomes a lost-marker line, never silence (5b). */
|
|
464
|
+
async saveAttachments(files) {
|
|
465
|
+
const markers = [];
|
|
463
466
|
for (const file of files) {
|
|
467
|
+
// Metadata check before the fetch: any type is welcome, but a movie
|
|
468
|
+
// buffered whole into memory is not.
|
|
469
|
+
if (file.size !== undefined && file.size > MAX_INBOUND_BYTES) {
|
|
470
|
+
markers.push(lostMarker(file.name ?? "attachment", "too large"));
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
464
473
|
try {
|
|
465
|
-
|
|
474
|
+
const { bytes, mimeType } = await this.api.downloadFile(file);
|
|
475
|
+
markers.push(fileMarker(await saveInbound(this.id, file.name, mimeType, bytes)));
|
|
466
476
|
}
|
|
467
477
|
catch (err) {
|
|
468
478
|
this.log(`file download failed: ${String(err)}`);
|
|
479
|
+
markers.push(lostMarker(file.name ?? "attachment", "download failed"));
|
|
469
480
|
}
|
|
470
481
|
}
|
|
471
|
-
return
|
|
482
|
+
return markers;
|
|
472
483
|
}
|
|
473
484
|
// --- outbound --------------------------------------------------------------
|
|
474
485
|
/**
|
|
@@ -62,7 +62,7 @@ export class TelegramApi {
|
|
|
62
62
|
async answerCallbackQuery(id, text) {
|
|
63
63
|
await this.call("answerCallbackQuery", { callback_query_id: id, text });
|
|
64
64
|
}
|
|
65
|
-
async
|
|
65
|
+
async downloadFile(fileId) {
|
|
66
66
|
const file = await this.call("getFile", { file_id: fileId });
|
|
67
67
|
if (!file.file_path)
|
|
68
68
|
throw new Error("telegram getFile: no file_path");
|
|
@@ -71,8 +71,7 @@ export class TelegramApi {
|
|
|
71
71
|
});
|
|
72
72
|
if (!res.ok)
|
|
73
73
|
throw new Error(`telegram file download: ${res.status}`);
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
return { data: buf.toString("base64"), mimeType };
|
|
74
|
+
const name = file.file_path.split("/").pop() || "file";
|
|
75
|
+
return { bytes: new Uint8Array(await res.arrayBuffer()), name };
|
|
77
76
|
}
|
|
78
77
|
}
|