@timqi/pier 0.0.9 → 0.0.16
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 +7 -1
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +190 -31
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +72 -0
- package/dist/db.js +78 -0
- package/dist/extensions/index.js +5 -2
- package/dist/extensions/web/artifacts.js +7 -2
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +33 -8
- package/dist/limits.js +14 -0
- package/dist/main.js +47 -6
- package/dist/paths.js +6 -1
- package/dist/settings.js +44 -0
- package/dist/tasks/agent.js +23 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +60 -12
- package/dist/tasks/execution.js +9 -1
- package/dist/tasks/groups.js +8 -4
- package/dist/tasks/messages.js +10 -2
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +7 -2
- package/dist/tasks/service.js +22 -6
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/tool.js +0 -12
- package/dist/tasks/types.js +4 -0
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +165 -36
- package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
- package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
- package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +22 -7
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +145 -64
- package/dist/web/session-state.js +33 -51
- package/dist/web/types.js +5 -0
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
- package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
package/dist/agent/pi.js
CHANGED
|
@@ -6,14 +6,29 @@ import { inlineExtensions } from "../extensions/index.js";
|
|
|
6
6
|
import { logger } from "../log.js";
|
|
7
7
|
import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
8
8
|
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
9
|
+
import { IndexedListing } from "./listing.js";
|
|
9
10
|
import { curateModels, pinFirst } from "./models.js";
|
|
10
11
|
const log = logger("agent");
|
|
12
|
+
/** A listed record as the seam reports it. The one mapping, because `list` and
|
|
13
|
+
* `find` answer with the same shape and drifting would mean two answers about
|
|
14
|
+
* one session. */
|
|
15
|
+
const summaryOf = (s) => ({
|
|
16
|
+
id: s.id,
|
|
17
|
+
cwd: s.cwd,
|
|
18
|
+
createdAt: s.created,
|
|
19
|
+
modified: s.modified,
|
|
20
|
+
...(s.title ? { title: s.title } : {}),
|
|
21
|
+
});
|
|
11
22
|
/** Pi's bash tool has no default timeout, so a hung command holds the turn
|
|
12
23
|
* until someone aborts it — nobody is watching in a scheduled task. Kept below
|
|
13
24
|
* the default task-run timeout so a stuck command comes back as
|
|
14
25
|
* a tool error the agent can retry with an explicit longer timeout, instead of
|
|
15
26
|
* killing the whole run. */
|
|
16
27
|
const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
28
|
+
/** How long a session listing stays usable: long enough that one workspace
|
|
29
|
+
* event, which several surfaces answer at once, scans disk once; short enough
|
|
30
|
+
* that a title no invalidation covers is never stale on screen. */
|
|
31
|
+
const LIST_TTL_MS = 3_000;
|
|
17
32
|
/** A probe nobody is watching is a hung page: the Console waits on this. */
|
|
18
33
|
const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
|
|
19
34
|
/** An ordinary budget, not a token: a 1-token cap is a request no real turn
|
|
@@ -27,12 +42,22 @@ const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspac
|
|
|
27
42
|
# Communication
|
|
28
43
|
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.
|
|
29
44
|
- Answer with the conclusion only. Reasons, process, trade-offs, alternatives: only when asked.
|
|
30
|
-
- Cap per reply:
|
|
45
|
+
- Cap per reply: 60 words (90 Chinese chars), max 3 bullets; 180 words (270 Chinese chars) when explicitly asked why or how. Code blocks, diffs and commands don't count.
|
|
46
|
+
- Reply in the language of the request; code, paths, identifiers and quoted output stay verbatim.
|
|
31
47
|
- Never: preamble, restating the question, closing summaries, "I'm going to..." narration, listing changes already visible in the diff.
|
|
32
48
|
- After edits, say only: file(s) touched + one line on the result. Don't explain self-evident code.
|
|
33
|
-
- Show file paths as \`path:line
|
|
49
|
+
- Show file paths as \`path:line\`, or the path alone when no single line is the point — never invent a number.
|
|
34
50
|
- If the honest answer needs more than the cap, give the conclusion plus one short "want the details?" — don't dump it.
|
|
35
|
-
- Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it
|
|
51
|
+
- Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.
|
|
52
|
+
|
|
53
|
+
# Working style (any machine)
|
|
54
|
+
These hold wherever Pier runs; a user's SYSTEM.md adds the local ones (which tools exist, which hosts, which paths).
|
|
55
|
+
- Orient first — list and search before you act. Never guess a path.
|
|
56
|
+
- Read before you edit. Match the surrounding code's style, naming, and comment density.
|
|
57
|
+
- Do exactly what was asked. No unrequested refactors, no extra files, no README updates.
|
|
58
|
+
- Destructive or irreversible actions (rm, force push, migrations, deploys): ask first.
|
|
59
|
+
- Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.
|
|
60
|
+
- Every bash call already runs in the working directory this prompt names — don't prefix \`cd <cwd> &&\`, \`cd\` only to go somewhere else. Each call is a fresh shell: \`cd\`, \`export\`, \`source\` never carry over, so chain what must share state into one command.`;
|
|
36
61
|
export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
|
|
37
62
|
/** Patching the call is cheaper than replacing the tool: the built-in keeps its
|
|
38
63
|
* shell settings, and the agent spends no tokens deciding a timeout. */
|
|
@@ -68,11 +93,21 @@ export const standDownShadowed = (base) => {
|
|
|
68
93
|
export class PiSession {
|
|
69
94
|
pi;
|
|
70
95
|
pinned;
|
|
96
|
+
wrote;
|
|
97
|
+
retention;
|
|
71
98
|
constructor(pi,
|
|
72
99
|
/** Operator pins, read per call — the menu can change while we run. */
|
|
73
|
-
pinned = () => []
|
|
100
|
+
pinned = () => [],
|
|
101
|
+
/** "What I just wrote is not in your listing yet." The factory retains a
|
|
102
|
+
* scan for a few seconds, which is exactly the window a rename lands in:
|
|
103
|
+
* every surface would re-read the old title and keep it until some
|
|
104
|
+
* unrelated event moved the list again. Same drop `create` and `fork`
|
|
105
|
+
* do — a callback only because the session is what knows it happened. */
|
|
106
|
+
wrote = () => { }, retention = { value: "long" }) {
|
|
74
107
|
this.pi = pi;
|
|
75
108
|
this.pinned = pinned;
|
|
109
|
+
this.wrote = wrote;
|
|
110
|
+
this.retention = retention;
|
|
76
111
|
}
|
|
77
112
|
/** Pi's dispose unhooks the one listener that persists and emits, so a turn
|
|
78
113
|
* started after it runs for real — model call, tools and all — and lands
|
|
@@ -128,6 +163,9 @@ export class PiSession {
|
|
|
128
163
|
setThinkingLevel(level) {
|
|
129
164
|
this.pi.setThinkingLevel(level);
|
|
130
165
|
}
|
|
166
|
+
setCacheRetention(retention) {
|
|
167
|
+
this.retention.value = retention;
|
|
168
|
+
}
|
|
131
169
|
async pendingQueue() {
|
|
132
170
|
return {
|
|
133
171
|
steering: [...this.pi.getSteeringMessages()],
|
|
@@ -158,11 +196,73 @@ export class PiSession {
|
|
|
158
196
|
if (cancelled)
|
|
159
197
|
throw new Error("rewind cancelled");
|
|
160
198
|
}
|
|
199
|
+
/** The compaction running right now, or null. Pi keeps no lock of its own —
|
|
200
|
+
* a second `compact()` aborts the first's turn and summarizes a transcript
|
|
201
|
+
* that is being replaced under it — and two POSTs a millisecond apart both
|
|
202
|
+
* pass the route's idle check, so the gate has to be here. */
|
|
203
|
+
compacting = null;
|
|
204
|
+
/** Pi's own compaction, minus its `CompactionResult`: the numbers reach
|
|
205
|
+
* surfaces as the `context-compacted` event the seam already emits for the
|
|
206
|
+
* automatic one, so a caller has nothing to do with them. Refused while one
|
|
207
|
+
* is running, rather than run twice over one context. */
|
|
208
|
+
async compact() {
|
|
209
|
+
this.live();
|
|
210
|
+
if (this.compacting)
|
|
211
|
+
throw new Error(`session ${this.pi.sessionId} is already compacting`);
|
|
212
|
+
// Started and recorded in the same tick, with no await between: that is
|
|
213
|
+
// what makes the check above a gate and not a hint.
|
|
214
|
+
const running = this.pi.compact().then(() => undefined);
|
|
215
|
+
this.compacting = running;
|
|
216
|
+
try {
|
|
217
|
+
await running;
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
this.compacting = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** One `session_info` entry, which Pi's own reader takes the latest of — so
|
|
224
|
+
* a rename is an append like everything else in a transcript, and nothing
|
|
225
|
+
* has to be rewritten. Never refused for being busy: a name has nothing to
|
|
226
|
+
* do with the turn running.
|
|
227
|
+
*
|
|
228
|
+
* Returns nothing, because the transcript is the answer: what the session is
|
|
229
|
+
* called after this — the name, or the title a cleared one falls back to —
|
|
230
|
+
* is what the next listing reads off the file, and deriving it here as well
|
|
231
|
+
* was a second copy of a rule agent/listing.ts already owns.
|
|
232
|
+
*
|
|
233
|
+
* TODO: renaming a cold session costs a whole resume, because the route
|
|
234
|
+
* reaches it through `ensure` and this method needs a live Pi session to
|
|
235
|
+
* append through. The work is one line in a file. Revisit when Pi offers a
|
|
236
|
+
* lightweight append to a session it has not loaded. */
|
|
237
|
+
async rename(name) {
|
|
238
|
+
this.live();
|
|
239
|
+
this.pi.sessionManager.appendSessionInfo(name);
|
|
240
|
+
this.wrote();
|
|
241
|
+
}
|
|
242
|
+
/** Compaction replaces the context a turn would run against, so a dispatch
|
|
243
|
+
* that lands mid-compaction waits for the summary instead of starting a turn
|
|
244
|
+
* over it — the follow-up promise ("delivered when idle") without Pi's
|
|
245
|
+
* follow-up queue, which is only drained by the *next* turn: a message
|
|
246
|
+
* parked there while nothing is running would sit unsent, and Pi's own
|
|
247
|
+
* `prompt()` guard would have thrown the user's message away (§5b). */
|
|
248
|
+
async whenCompacted() {
|
|
249
|
+
while (this.compacting)
|
|
250
|
+
await this.compacting.catch(() => undefined);
|
|
251
|
+
}
|
|
161
252
|
// Async, so a refusal is a rejected promise: the seam promises callers they
|
|
162
253
|
// may only `.catch()` (core/types.ts), and dispatch does exactly that.
|
|
163
254
|
async prompt(text) {
|
|
164
255
|
this.live();
|
|
165
|
-
|
|
256
|
+
await this.whenCompacted();
|
|
257
|
+
// Re-checked: the wait above is long enough for a dispose to land.
|
|
258
|
+
this.live();
|
|
259
|
+
// A turn may have started since the caller read the state this prompt was
|
|
260
|
+
// decided against — two messages arriving together, or several released at
|
|
261
|
+
// once by the wait above. Bare, Pi throws that back as "already
|
|
262
|
+
// processing" and the message is gone (§5b); queued, it is the same
|
|
263
|
+
// "delivered when idle" the core's own policy picks for an auto message
|
|
264
|
+
// that lands mid-turn (core/queue.ts).
|
|
265
|
+
return this.pi.prompt(text, { streamingBehavior: "followUp" });
|
|
166
266
|
}
|
|
167
267
|
async steer(text) {
|
|
168
268
|
this.live();
|
|
@@ -173,6 +273,11 @@ export class PiSession {
|
|
|
173
273
|
return this.pi.followUp(text);
|
|
174
274
|
}
|
|
175
275
|
async systemInput(text, origin, mode) {
|
|
276
|
+
this.live();
|
|
277
|
+
// Same gate as prompt(): an idle session takes a system input as a turn
|
|
278
|
+
// whatever the mode says, so a callback landing mid-compaction would race
|
|
279
|
+
// the summary too.
|
|
280
|
+
await this.whenCompacted();
|
|
176
281
|
this.live();
|
|
177
282
|
return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
|
|
178
283
|
}
|
|
@@ -208,6 +313,7 @@ export class PiAgentFactory {
|
|
|
208
313
|
providerConfig;
|
|
209
314
|
pinned;
|
|
210
315
|
enabledExtensions;
|
|
316
|
+
listings;
|
|
211
317
|
constructor(extraTools = [],
|
|
212
318
|
/** Appended as a virtual context file, so Pi's own prompt stays intact.
|
|
213
319
|
* Read per session, not captured once: it carries settings a user can
|
|
@@ -227,7 +333,10 @@ export class PiAgentFactory {
|
|
|
227
333
|
pinned = () => [],
|
|
228
334
|
/** Which bundled extensions the Console has switched on. A getter for the
|
|
229
335
|
* same reason again: the toggle takes effect on the next session open. */
|
|
230
|
-
enabledExtensions = () => []
|
|
336
|
+
enabledExtensions = () => [],
|
|
337
|
+
/** What exists on disk. Injected so a test can hand this factory a listing
|
|
338
|
+
* instead of a session directory and a database. */
|
|
339
|
+
listings = new IndexedListing()) {
|
|
231
340
|
this.extraTools = extraTools;
|
|
232
341
|
this.instructions = instructions;
|
|
233
342
|
this.skillPaths = skillPaths;
|
|
@@ -235,14 +344,20 @@ export class PiAgentFactory {
|
|
|
235
344
|
this.providerConfig = providerConfig;
|
|
236
345
|
this.pinned = pinned;
|
|
237
346
|
this.enabledExtensions = enabledExtensions;
|
|
347
|
+
this.listings = listings;
|
|
238
348
|
}
|
|
239
349
|
/** One runtime for the whole process; catalogs are global, not per session. */
|
|
240
350
|
catalog;
|
|
241
|
-
/** Where each listed session lives.
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
351
|
+
/** Where each listed session lives. A scan still stats every session file on
|
|
352
|
+
* disk, which `resume` would pay on every cold open — web selection, an IM
|
|
353
|
+
* message, a task run. The sidebar's own listing keeps this warm; a miss
|
|
354
|
+
* still lists. */
|
|
245
355
|
located = new Map();
|
|
356
|
+
/** That same scan, retained for LIST_TTL_MS instead of paid once per asking
|
|
357
|
+
* surface — one workspace event has three (sidebar, Activity, task lookups).
|
|
358
|
+
* Dropped on create/fork; ids appear for reasons this factory never sees, so
|
|
359
|
+
* a miss that decides something re-lists rather than trusts it (`resume`). */
|
|
360
|
+
listing;
|
|
246
361
|
refreshQueue = Promise.resolve();
|
|
247
362
|
builtinProviderIds;
|
|
248
363
|
/** Structural fit: CredentialStore mirrors pi-ai's interface of the same
|
|
@@ -457,8 +572,12 @@ export class PiAgentFactory {
|
|
|
457
572
|
}
|
|
458
573
|
async open(cwd, sessionManager, opts = { cwd }) {
|
|
459
574
|
let live;
|
|
575
|
+
// Asked per open, not captured at wiring: a tool whose channel is not
|
|
576
|
+
// configured yet would otherwise cost context on every turn of every
|
|
577
|
+
// session and be able to answer nothing.
|
|
578
|
+
const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
|
|
460
579
|
// Generic translation only — tool contracts are data owned by their feature.
|
|
461
|
-
const customTools =
|
|
580
|
+
const customTools = active.map((tool) => defineTool({
|
|
462
581
|
name: tool.name,
|
|
463
582
|
label: tool.label,
|
|
464
583
|
description: tool.description,
|
|
@@ -493,19 +612,27 @@ export class PiAgentFactory {
|
|
|
493
612
|
this.credentials?.assertUnlocked();
|
|
494
613
|
if (opts.name)
|
|
495
614
|
sessionManager.appendSessionInfo(opts.name);
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
615
|
+
// This runtime serves exactly this one session, so shadowing its
|
|
616
|
+
// streamSimple is the per-session seam for the Anthropic cache TTL:
|
|
617
|
+
// interactive sessions keep "long" (1h — turns arrive minutes apart),
|
|
618
|
+
// tasks downgrade to "short" (5m) via setCacheRetention. The default sits
|
|
619
|
+
// before the spread so an explicit per-request value still wins —
|
|
620
|
+
// compaction passes cacheRetention: "none" and must keep it.
|
|
621
|
+
const runtime = await this.createRuntime();
|
|
622
|
+
const retention = { value: "long" };
|
|
623
|
+
const stream = runtime.streamSimple.bind(runtime);
|
|
624
|
+
runtime.streamSimple = ((model, context, options) => stream(model, context, { cacheRetention: retention.value, ...options }));
|
|
499
625
|
const created = await createAgentSession({
|
|
500
626
|
cwd,
|
|
501
627
|
sessionManager,
|
|
502
628
|
customTools,
|
|
503
|
-
|
|
504
|
-
modelRuntime: await this.createRuntime(),
|
|
629
|
+
modelRuntime: runtime,
|
|
505
630
|
resourceLoader: await this.resourceLoader(cwd),
|
|
506
631
|
});
|
|
507
632
|
live = created.session;
|
|
508
|
-
const session = new PiSession(live, this.pinned)
|
|
633
|
+
const session = new PiSession(live, this.pinned, () => {
|
|
634
|
+
this.listing = undefined;
|
|
635
|
+
}, retention);
|
|
509
636
|
if (opts.model)
|
|
510
637
|
await session.setModel(opts.model);
|
|
511
638
|
if (opts.thinking)
|
|
@@ -514,6 +641,7 @@ export class PiAgentFactory {
|
|
|
514
641
|
return session;
|
|
515
642
|
}
|
|
516
643
|
async create(opts) {
|
|
644
|
+
this.listing = undefined;
|
|
517
645
|
return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
|
|
518
646
|
}
|
|
519
647
|
async fork(sourceSessionId, opts) {
|
|
@@ -533,6 +661,7 @@ export class PiAgentFactory {
|
|
|
533
661
|
if (!leafId)
|
|
534
662
|
throw new Error("cannot fork a session before its first persisted input");
|
|
535
663
|
manager.createBranchedSession(leafId);
|
|
664
|
+
this.listing = undefined;
|
|
536
665
|
return this.open(opts.cwd, manager, opts);
|
|
537
666
|
}
|
|
538
667
|
async resume(sessionId) {
|
|
@@ -548,27 +677,57 @@ export class PiAgentFactory {
|
|
|
548
677
|
this.located.delete(sessionId);
|
|
549
678
|
}
|
|
550
679
|
}
|
|
551
|
-
const
|
|
552
|
-
const info = infos.find((s) => s.id === sessionId);
|
|
680
|
+
const info = await this.locate(sessionId);
|
|
553
681
|
if (!info)
|
|
554
682
|
throw new Error(`unknown session: ${sessionId}`);
|
|
555
683
|
return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
|
|
556
684
|
}
|
|
685
|
+
/** The listed record for one id, and the one place "no such session" is
|
|
686
|
+
* decided. A retained listing is not evidence that a session is gone: it may
|
|
687
|
+
* have been written since — by another Pier, or by the first turn of a
|
|
688
|
+
* session this factory opened. The miss is what earns a fresh scan, because
|
|
689
|
+
* callers read it as permission to start a replacement session
|
|
690
|
+
* (channels/conversations.ts), which costs a conversation its history, or as
|
|
691
|
+
* a session that no longer exists (tasks/, web/files.ts). `reused` is how we
|
|
692
|
+
* know a scan is owed: same entry back, same disk state. */
|
|
693
|
+
async locate(sessionId) {
|
|
694
|
+
const find = (infos) => infos.find((s) => s.id === sessionId);
|
|
695
|
+
const reused = this.listing;
|
|
696
|
+
return find(await this.listed()) ??
|
|
697
|
+
(reused && this.listing === reused ? find(await this.listed(true)) : undefined);
|
|
698
|
+
}
|
|
699
|
+
async find(sessionId) {
|
|
700
|
+
const info = await this.locate(sessionId);
|
|
701
|
+
return info ? summaryOf(info) : undefined;
|
|
702
|
+
}
|
|
703
|
+
/** Once per process, after the first listing: agent/listing.ts reads Pi's
|
|
704
|
+
* transcripts with a parser of its own, and only a comparison notices when
|
|
705
|
+
* that format moves under it. */
|
|
706
|
+
audited = false;
|
|
557
707
|
/** Every listing goes through here, so it also refreshes `located`. */
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
this.
|
|
562
|
-
|
|
708
|
+
listed(force = false) {
|
|
709
|
+
const now = Date.now();
|
|
710
|
+
if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
|
|
711
|
+
return this.listing.infos;
|
|
712
|
+
const infos = this.listings.scan().then((listed) => {
|
|
713
|
+
for (const s of listed) {
|
|
714
|
+
this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
|
|
715
|
+
}
|
|
716
|
+
if (!this.audited && this.listings.audit) {
|
|
717
|
+
this.audited = true;
|
|
718
|
+
void this.listings.audit(() => SessionManager.listAll()).then((wrong) => wrong || log.debug("session index agrees with Pi's own listing"), (err) => log.warn("session index cross-check failed", err));
|
|
719
|
+
}
|
|
720
|
+
return listed;
|
|
721
|
+
});
|
|
722
|
+
// A failed scan is not an answer to hand the next caller for three seconds.
|
|
723
|
+
void infos.catch(() => {
|
|
724
|
+
if (this.listing?.infos === infos)
|
|
725
|
+
this.listing = undefined;
|
|
726
|
+
});
|
|
727
|
+
this.listing = { at: now, infos };
|
|
563
728
|
return infos;
|
|
564
729
|
}
|
|
565
730
|
async list() {
|
|
566
|
-
|
|
567
|
-
return infos.map((s) => ({
|
|
568
|
-
id: s.id,
|
|
569
|
-
cwd: s.cwd,
|
|
570
|
-
createdAt: s.created.getTime(),
|
|
571
|
-
title: s.name ?? (s.firstMessage ? s.firstMessage.slice(0, 80) : undefined),
|
|
572
|
-
}));
|
|
731
|
+
return (await this.listed()).map(summaryOf);
|
|
573
732
|
}
|
|
574
733
|
}
|
package/dist/boards/boards.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
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
7
|
// made. `/boards/*` is authenticated; `/p/*` additionally requires the
|
|
8
|
-
// manifest's `public` flag and runs as sandboxed active content
|
|
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.
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
12
|
import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
10
13
|
import { extname, join, resolve, sep } from "node:path";
|
|
11
14
|
import { logger } from "../log.js";
|
|
@@ -14,6 +17,13 @@ export const defaultBoardsDir = () => pierPath("boards");
|
|
|
14
17
|
/** Deleted boards keep their bytes under `<slug>.deleted-<ts>`, which this
|
|
15
18
|
* pattern excludes from every scan — one rename is the whole delete path. */
|
|
16
19
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
20
|
+
/** A published board is addressed by `<slug>-<token>`: the slug alone is a
|
|
21
|
+
* guessable word, so without the 32 bits after it `/p/` could be walked with
|
|
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. */
|
|
25
|
+
const TOKEN = /^[a-f0-9]{8}$/;
|
|
26
|
+
const mintToken = () => randomBytes(4).toString("hex");
|
|
17
27
|
// A board ships fonts and images, so the list is wider than the attachment
|
|
18
28
|
// route's — but still a whitelist: an unlisted extension is not served at all.
|
|
19
29
|
const TYPES = {
|
|
@@ -49,7 +59,10 @@ const warned = new Set();
|
|
|
49
59
|
* here and nowhere else: an unvalidated `../../etc` would read outside the
|
|
50
60
|
* boards dir, and a NUL byte would throw instead of 404. Unknown fields get
|
|
51
61
|
* defaults, a broken file is skipped whole, and extra keys are the agent's
|
|
52
|
-
* business — they survive a write.
|
|
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. */
|
|
53
66
|
async function readManifest(dir, slug) {
|
|
54
67
|
if (!SLUG.test(slug))
|
|
55
68
|
return null;
|
|
@@ -69,14 +82,29 @@ async function readManifest(dir, slug) {
|
|
|
69
82
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
70
83
|
return null;
|
|
71
84
|
const m = raw;
|
|
72
|
-
|
|
85
|
+
const manifest = {
|
|
73
86
|
...m,
|
|
74
87
|
title: typeof m.title === "string" && m.title ? m.title : slug,
|
|
75
88
|
description: typeof m.description === "string" ? m.description : "",
|
|
76
89
|
sessions: Array.isArray(m.sessions) ? m.sessions.filter((s) => typeof s === "string") : [],
|
|
77
90
|
public: m.public === true,
|
|
91
|
+
token: typeof m.token === "string" && TOKEN.test(m.token) ? m.token : "",
|
|
78
92
|
};
|
|
93
|
+
if (manifest.public && !manifest.token) {
|
|
94
|
+
manifest.token = mintToken();
|
|
95
|
+
try {
|
|
96
|
+
await writeManifest(dir, slug, manifest);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
// A token that cannot be stored would differ on the next request, so the
|
|
100
|
+
// board stays unreachable on /p/ rather than handing out a dead link.
|
|
101
|
+
logger("boards").warn(`cannot mint a public token for ${slug}`, err);
|
|
102
|
+
manifest.token = "";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return manifest;
|
|
79
106
|
}
|
|
107
|
+
const writeManifest = (dir, slug, manifest) => writeFile(join(dir, slug, "board.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
80
108
|
/** Freshness is the site's mtime, not a manifest field — the filesystem
|
|
81
109
|
* already knows, and an agent rewriting a page cannot forget to say so. */
|
|
82
110
|
async function updatedAt(dir, slug) {
|
|
@@ -99,8 +127,8 @@ export async function listBoards(dir) {
|
|
|
99
127
|
const manifest = await readManifest(dir, slug);
|
|
100
128
|
if (!manifest)
|
|
101
129
|
continue;
|
|
102
|
-
const { title, description, sessions, public: isPublic } = manifest;
|
|
103
|
-
boards.push({ slug, title, description, sessions, public: isPublic, updatedAt: await updatedAt(dir, slug) });
|
|
130
|
+
const { title, description, sessions, public: isPublic, token } = manifest;
|
|
131
|
+
boards.push({ slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) });
|
|
104
132
|
}
|
|
105
133
|
return boards;
|
|
106
134
|
}
|
|
@@ -139,11 +167,28 @@ async function resolveFile(dir, slug, rest) {
|
|
|
139
167
|
return resolveFile(dir, slug, `${relative}/`);
|
|
140
168
|
return info.isFile() ? file : null;
|
|
141
169
|
}
|
|
142
|
-
|
|
170
|
+
/** `/p/` addresses a board as `<slug>-<token>`; a slug may itself contain
|
|
171
|
+
* hyphens, so the last one is the cut. */
|
|
172
|
+
function publicKey(key) {
|
|
173
|
+
const cut = key.lastIndexOf("-");
|
|
174
|
+
return cut < 1 ? { slug: "", token: "" } : { slug: key.slice(0, cut), token: key.slice(cut + 1) };
|
|
175
|
+
}
|
|
176
|
+
/** Digested first: the token is a secret, and a URL's half may be any length
|
|
177
|
+
* or encoding, which a raw comparison would either leak or throw on. */
|
|
178
|
+
const sameToken = (want, got) => {
|
|
179
|
+
if (!want)
|
|
180
|
+
return false;
|
|
181
|
+
const digest = (s) => createHash("sha256").update(s).digest();
|
|
182
|
+
return timingSafeEqual(digest(want), digest(got));
|
|
183
|
+
};
|
|
184
|
+
async function serveFile(c, dir, key, rest, publicOnly) {
|
|
185
|
+
const { slug, token } = publicOnly ? publicKey(key) : { slug: key, token: "" };
|
|
143
186
|
const manifest = await readManifest(dir, slug);
|
|
144
|
-
// 404, never 403: a private board's existence is not public information
|
|
145
|
-
|
|
187
|
+
// 404, never 403: a private board's existence is not public information, and
|
|
188
|
+
// a wrong token is the same non-answer as a wrong name.
|
|
189
|
+
if (!manifest || (publicOnly && (!manifest.public || !sameToken(manifest.token, token)))) {
|
|
146
190
|
return c.notFound();
|
|
191
|
+
}
|
|
147
192
|
const file = await resolveFile(dir, slug, rest);
|
|
148
193
|
if (!file)
|
|
149
194
|
return c.notFound();
|
|
@@ -179,8 +224,12 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
|
|
179
224
|
if (!manifest)
|
|
180
225
|
return c.json({ error: "no such board" }, 404);
|
|
181
226
|
manifest.public = body.public;
|
|
182
|
-
|
|
183
|
-
|
|
227
|
+
// Publishing an unpublished board is the case readManifest cannot mint for:
|
|
228
|
+
// it read the manifest while it was still private.
|
|
229
|
+
if (manifest.public && !manifest.token)
|
|
230
|
+
manifest.token = mintToken();
|
|
231
|
+
await writeManifest(dir, slug, manifest);
|
|
232
|
+
return c.json({ public: manifest.public, token: manifest.token });
|
|
184
233
|
});
|
|
185
234
|
app.delete("/api/boards/:slug", async (c) => {
|
|
186
235
|
const slug = c.req.param("slug");
|
|
@@ -190,7 +239,7 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
|
|
190
239
|
return c.json({ deleted: slug });
|
|
191
240
|
});
|
|
192
241
|
// Declared before the wildcards below: `_assets` is not a slug.
|
|
193
|
-
app.get("/
|
|
242
|
+
app.get("/p/_assets/pier.css", async (c) => {
|
|
194
243
|
const file = new URL("./pier.css", import.meta.url);
|
|
195
244
|
return c.body(await readFile(file), 200, {
|
|
196
245
|
"content-type": "text/css; charset=utf-8",
|
|
@@ -200,11 +249,11 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
|
|
200
249
|
// Trailing slash matters: without it a board's relative asset paths resolve
|
|
201
250
|
// against /boards instead of the board.
|
|
202
251
|
for (const prefix of ["/boards", "/p"]) {
|
|
203
|
-
app.get(`${prefix}/:
|
|
204
|
-
app.get(`${prefix}/:
|
|
205
|
-
const
|
|
206
|
-
const rest = c.req.path.slice(`${prefix}/${
|
|
207
|
-
return serveFile(c, dir,
|
|
252
|
+
app.get(`${prefix}/:key`, (c) => c.redirect(`${prefix}/${c.req.param("key")}/`));
|
|
253
|
+
app.get(`${prefix}/:key/*`, (c) => {
|
|
254
|
+
const key = c.req.param("key");
|
|
255
|
+
const rest = c.req.path.slice(`${prefix}/${key}/`.length);
|
|
256
|
+
return serveFile(c, dir, key, rest, prefix === "/p");
|
|
208
257
|
});
|
|
209
258
|
}
|
|
210
259
|
}
|
package/dist/boards/pier.css
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* pier.css — the one stylesheet every Board can link, served at
|
|
2
|
-
/
|
|
2
|
+
/p/_assets/pier.css. Classless on purpose: plain semantic HTML must
|
|
3
3
|
look finished, so a board needs no build step and no framework. A handful of
|
|
4
4
|
named helpers cover the shapes a report actually asks for. */
|
|
5
5
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Outbound attachments: which links in a turn are files the platform has to
|
|
2
|
+
// carry, and the bytes behind them.
|
|
3
|
+
//
|
|
4
|
+
// The agent links a file it produced by absolute `file://` URL — the
|
|
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.
|
|
15
|
+
import { readFile, stat } from "node:fs/promises";
|
|
16
|
+
import { basename, extname } from "node:path";
|
|
17
|
+
import { lostMarker } from "../core/inbound-file.js";
|
|
18
|
+
/**
|
|
19
|
+
* One cap for every platform: Telegram refuses a photo past 10 MB, which is
|
|
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
|
+
*/
|
|
23
|
+
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
|
+
const MAX_ATTACHMENTS = 5;
|
|
26
|
+
/** Extensions the platforms show inline. Everything else goes as a document —
|
|
27
|
+
* svg included, deliberately: it is markup, and it renders as a file. */
|
|
28
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
|
29
|
+
/** `[label](file:///abs/path)`, inline or on a line of its own. The optional
|
|
30
|
+
* `!` is an image embed, which is the same request with a different sigil. */
|
|
31
|
+
const LINK = /!?\[([^\]\n]*)\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)/g;
|
|
32
|
+
/** A trailing slash or a bare root would otherwise leave an unnamed file. */
|
|
33
|
+
const nameOf = (path) => basename(path) || "file";
|
|
34
|
+
/**
|
|
35
|
+
* Split a turn's markdown into the text an IM chat should show and the files
|
|
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.
|
|
39
|
+
*/
|
|
40
|
+
export function splitAttachments(markdown) {
|
|
41
|
+
const paths = [];
|
|
42
|
+
const text = markdown.replace(LINK, (_m, label, raw) => {
|
|
43
|
+
let path = raw;
|
|
44
|
+
try {
|
|
45
|
+
path = decodeURIComponent(raw);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* not percent-encoded — take the path as written */
|
|
49
|
+
}
|
|
50
|
+
if (!paths.includes(path))
|
|
51
|
+
paths.push(path);
|
|
52
|
+
return label || nameOf(path);
|
|
53
|
+
});
|
|
54
|
+
return { text, paths };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Upload every file a turn linked, and return the line the conversation still
|
|
58
|
+
* owes: an attachment that never arrived must not look like an attachment that
|
|
59
|
+
* was never mentioned (AGENTS.md 5b), so each failure is named in the chat as
|
|
60
|
+
* well as in the log. Empty string when everything landed.
|
|
61
|
+
*/
|
|
62
|
+
export async function sendAttachments(paths, upload, log) {
|
|
63
|
+
const lost = [];
|
|
64
|
+
const fail = (path, reason) => {
|
|
65
|
+
log(`attachment ${path} not sent: ${reason}`);
|
|
66
|
+
lost.push(lostMarker(nameOf(path), reason));
|
|
67
|
+
};
|
|
68
|
+
for (const path of paths.slice(0, MAX_ATTACHMENTS)) {
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(path);
|
|
71
|
+
if (!info.isFile())
|
|
72
|
+
throw new Error("not a file");
|
|
73
|
+
if (info.size > MAX_ATTACH_BYTES)
|
|
74
|
+
throw new Error(`too large (>${MAX_ATTACH_BYTES} bytes)`);
|
|
75
|
+
const name = nameOf(path);
|
|
76
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
77
|
+
await upload({ name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
fail(path, err instanceof Error ? err.message : String(err));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const path of paths.slice(MAX_ATTACHMENTS)) {
|
|
84
|
+
fail(path, `more than ${MAX_ATTACHMENTS} files in one turn`);
|
|
85
|
+
}
|
|
86
|
+
return lost.join("\n");
|
|
87
|
+
}
|
package/dist/channels/control.js
CHANGED
|
@@ -29,11 +29,11 @@ export function createControl({ router, factory, conversations, store }) {
|
|
|
29
29
|
if (!session)
|
|
30
30
|
return null;
|
|
31
31
|
// AgentSession has no cwd; the factory's listing is where it lives.
|
|
32
|
-
const
|
|
32
|
+
const summary = await factory.find(session.id);
|
|
33
33
|
const usage = session.contextUsage;
|
|
34
34
|
return {
|
|
35
35
|
sessionId: session.id,
|
|
36
|
-
cwd:
|
|
36
|
+
cwd: summary?.cwd ?? "",
|
|
37
37
|
state: session.state,
|
|
38
38
|
model: session.model,
|
|
39
39
|
thinking: session.thinkingLevel,
|
|
@@ -136,6 +136,44 @@ export class LarkApi {
|
|
|
136
136
|
});
|
|
137
137
|
return { messageId: ok("message.reply", res).data?.message_id ?? "" };
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Two calls: the bytes go to the platform first and come back as a key,
|
|
141
|
+
* then the key is posted as a message. Images take the image endpoint so
|
|
142
|
+
* they render inline; everything else is a `stream` file, which is Lark's
|
|
143
|
+
* name for "a file whose type I am not claiming to know".
|
|
144
|
+
*
|
|
145
|
+
* The SDK unwraps an upload response to its `data`, so a business failure
|
|
146
|
+
* arrives as a missing key rather than as a code — hence the explicit throw
|
|
147
|
+
* instead of `ok()`.
|
|
148
|
+
*/
|
|
149
|
+
async uploadFile(rootId, file) {
|
|
150
|
+
const bytes = Buffer.from(file.bytes);
|
|
151
|
+
let content;
|
|
152
|
+
if (file.image) {
|
|
153
|
+
const res = await this.client.im.v1.image.create({
|
|
154
|
+
data: { image_type: "message", image: bytes },
|
|
155
|
+
});
|
|
156
|
+
if (!res?.image_key)
|
|
157
|
+
throw new Error(`lark image.create: no image_key for ${file.name}`);
|
|
158
|
+
content = { image_key: res.image_key };
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const res = await this.client.im.v1.file.create({
|
|
162
|
+
data: { file_type: "stream", file_name: file.name, file: bytes },
|
|
163
|
+
});
|
|
164
|
+
if (!res?.file_key)
|
|
165
|
+
throw new Error(`lark file.create: no file_key for ${file.name}`);
|
|
166
|
+
content = { file_key: res.file_key };
|
|
167
|
+
}
|
|
168
|
+
ok("message.reply", await this.client.im.v1.message.reply({
|
|
169
|
+
path: { message_id: rootId },
|
|
170
|
+
data: {
|
|
171
|
+
msg_type: file.image ? "image" : "file",
|
|
172
|
+
content: JSON.stringify(content),
|
|
173
|
+
reply_in_thread: true,
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
139
177
|
async patchCard(messageId, card) {
|
|
140
178
|
ok("message.patch", await this.client.im.v1.message.patch({
|
|
141
179
|
path: { message_id: messageId },
|