@timqi/pier 0.1.14 → 0.3.0
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 +44 -22
- package/dist/agent/config.js +2 -2
- package/dist/agent/events.js +32 -3
- package/dist/agent/listing.js +14 -10
- package/dist/agent/packages.js +6 -10
- package/dist/agent/pi.js +113 -70
- package/dist/agent/roles.js +104 -0
- package/dist/agent/types.js +78 -0
- package/dist/boards/boards.js +42 -32
- package/dist/channels/chains.js +0 -4
- package/dist/channels/commands.js +7 -12
- package/dist/channels/config.js +27 -1
- package/dist/channels/control.js +5 -16
- package/dist/channels/conversations.js +4 -9
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +21 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +81 -39
- package/dist/channels/lines.js +33 -1
- package/dist/channels/panel.js +17 -107
- package/dist/channels/routes.js +11 -21
- package/dist/channels/runtime.js +79 -25
- package/dist/channels/slack-outbound.js +12 -14
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack-thread.js +65 -0
- package/dist/channels/slack.js +85 -124
- package/dist/channels/types.js +1 -1
- package/dist/cli.js +7 -2
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +221 -0
- package/dist/core/identity.js +36 -13
- package/dist/core/inbound-file.js +11 -4
- package/dist/core/reply.js +111 -81
- package/dist/core/router.js +111 -118
- package/dist/core/search.js +65 -0
- package/dist/core/types.js +38 -77
- package/dist/db.js +41 -2
- package/dist/drain.js +1 -1
- package/dist/main.js +65 -40
- package/dist/settings.js +26 -19
- package/dist/socket.js +1 -0
- package/dist/tasks/agent.js +11 -5
- package/dist/tasks/callbacks.js +55 -5
- package/dist/tasks/cli.js +22 -11
- package/dist/tasks/definitions.js +30 -17
- package/dist/tasks/execution.js +11 -5
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +88 -0
- package/dist/tasks/operations.js +76 -39
- package/dist/tasks/outbox.js +19 -6
- package/dist/tasks/routes.js +19 -249
- package/dist/tasks/service.js +166 -34
- package/dist/tasks/store.js +123 -57
- package/dist/tasks/types.js +3 -0
- package/dist/tools.js +12 -5
- package/dist/web/auth.js +2 -2
- package/dist/web/config-sync.js +4 -5
- package/dist/web/config.js +9 -2
- package/dist/web/explorer.js +2 -2
- package/dist/web/fs.js +16 -1
- package/dist/web/instance.js +8 -5
- package/dist/web/packages.js +1 -1
- package/dist/web/passkeys.js +3 -3
- package/dist/web/providers.js +3 -3
- package/dist/web/public/assets/code-CWT27KH1.js +2 -0
- package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
- package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
- package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
- package/dist/web/public/index.html +39 -109
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +0 -2
- package/dist/web/push.js +3 -3
- package/dist/web/server.js +89 -116
- package/dist/web/session-state.js +4 -40
- package/docs/deploy.md +5 -3
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +14 -4
- package/skills/pier-help/SKILL.md +62 -23
- package/skills/pier-search/SKILL.md +27 -0
- package/skills/pier-tasks/SKILL.md +68 -18
- package/dist/channels/handoff.js +0 -94
- package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
- package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
- package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
- package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
- package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js +0 -85
- package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
- package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
- package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
- package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
- package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
- package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
package/dist/agent/pi.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// Pi SDK. No Pi type may appear in an exported signature.
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
|
-
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, sessionEntryToContextMessages, } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { SESSION_TITLE_MAX } from "../core/types.js";
|
|
8
8
|
import { logger } from "../log.js";
|
|
9
|
+
import { pierPath } from "../paths.js";
|
|
10
|
+
import { DISPATCHER, LEAD } from "./roles.js";
|
|
9
11
|
import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
10
12
|
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
11
13
|
import { IndexedListing } from "./listing.js";
|
|
@@ -99,7 +101,7 @@ These rules govern conversational replies. A human reads them on a phone-sized s
|
|
|
99
101
|
- Before touching files: list and search first. Never guess a path or a line number.
|
|
100
102
|
- Read before you edit. Match the surrounding code's style, naming, and comment density.
|
|
101
103
|
- Do exactly what was asked. No unrequested refactors, no extra files, no README updates.
|
|
102
|
-
- Each bash call is a fresh shell in the working directory; chain what must share state.
|
|
104
|
+
- Each bash call is a fresh shell in the working directory; chain what must share state. Don't prefix commands with \`cd\` to that same directory — use relative paths; \`cd\` only to go elsewhere.
|
|
103
105
|
- Destructive or irreversible actions on things you didn't create — deleting user files, force push, migrations, deploys, service restarts: ask first; unattended, don't do them and report what you would have done.
|
|
104
106
|
- Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
|
|
105
107
|
export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
|
|
@@ -111,25 +113,37 @@ const bashTimeoutDefault = (pi) => {
|
|
|
111
113
|
}
|
|
112
114
|
});
|
|
113
115
|
};
|
|
116
|
+
/** The transcript's current branch in order, compacted entries included. */
|
|
117
|
+
const branchMessages = (sessionManager) => sessionManager.getBranch().flatMap((entry) => sessionEntryToContextMessages(entry));
|
|
118
|
+
/** Where a session compacts — a main session
|
|
119
|
+
* and a session a run launched from a session made (a lead or a worker, which
|
|
120
|
+
* never rotate); above 200K input, 1M-context models price higher. */
|
|
121
|
+
const MAIN_COMPACTION_CAP = 100_000;
|
|
122
|
+
const CHILD_COMPACTION_CAP = 150_000;
|
|
114
123
|
export class PiSession {
|
|
115
124
|
pi;
|
|
116
125
|
pinned;
|
|
117
126
|
wrote;
|
|
118
127
|
retention;
|
|
119
128
|
suggestTitle;
|
|
129
|
+
cap;
|
|
120
130
|
constructor(pi,
|
|
121
131
|
/** Read per call — the menu can change while we run. */
|
|
122
132
|
pinned = () => [],
|
|
123
|
-
/** Drops the factory's retained listing: a
|
|
124
|
-
* window it covers, and every surface would keep the old
|
|
133
|
+
/** Drops the factory's retained listing: a title lands in exactly the
|
|
134
|
+
* window it covers, and every surface would keep the old one. */
|
|
125
135
|
wrote = () => { }, retention = { value: "long" },
|
|
126
136
|
/** Read per turn: switching auto-titling on takes effect without a restart. */
|
|
127
|
-
suggestTitle = () => undefined
|
|
137
|
+
suggestTitle = () => undefined,
|
|
138
|
+
/** Auto-compaction triggers once the context passes this many tokens. */
|
|
139
|
+
cap) {
|
|
128
140
|
this.pi = pi;
|
|
129
141
|
this.pinned = pinned;
|
|
130
142
|
this.wrote = wrote;
|
|
131
143
|
this.retention = retention;
|
|
132
144
|
this.suggestTitle = suggestTitle;
|
|
145
|
+
this.cap = cap;
|
|
146
|
+
this.applyCap();
|
|
133
147
|
}
|
|
134
148
|
/** A turn started after Pi's dispose runs for real and lands nowhere — no
|
|
135
149
|
* transcript, no event, a promise that resolves. Refusing makes it a failure (§5). */
|
|
@@ -153,7 +167,7 @@ export class PiSession {
|
|
|
153
167
|
}
|
|
154
168
|
get contextUsage() {
|
|
155
169
|
const u = this.pi.getContextUsage();
|
|
156
|
-
return u ? { tokens: u.tokens, contextWindow: u.contextWindow } : undefined;
|
|
170
|
+
return u ? { tokens: u.tokens, contextWindow: u.contextWindow, compactAt: u.contextWindow - this.reserve(u.contextWindow) } : undefined;
|
|
157
171
|
}
|
|
158
172
|
async setModel(ref) {
|
|
159
173
|
const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
|
|
@@ -163,6 +177,8 @@ export class PiSession {
|
|
|
163
177
|
throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
|
|
164
178
|
}
|
|
165
179
|
await this.pi.setModel(m);
|
|
180
|
+
// The reserve is per context window, so a cap outlives a model switch only if recomputed.
|
|
181
|
+
this.applyCap();
|
|
166
182
|
}
|
|
167
183
|
async availableModels() {
|
|
168
184
|
const available = await this.pi.modelRuntime.getAvailable();
|
|
@@ -183,6 +199,27 @@ export class PiSession {
|
|
|
183
199
|
setCacheRetention(retention) {
|
|
184
200
|
this.retention.value = retention;
|
|
185
201
|
}
|
|
202
|
+
instanceReserve;
|
|
203
|
+
/** Pi compacts past `contextWindow − reserveTokens`; this session's settings
|
|
204
|
+
* manager is its own, so the override reaches no other session. Never later
|
|
205
|
+
* than the instance's own reserve. */
|
|
206
|
+
applyCap() {
|
|
207
|
+
const window = this.pi.model?.contextWindow;
|
|
208
|
+
if (this.cap === undefined || !window)
|
|
209
|
+
return;
|
|
210
|
+
this.pi.settingsManager.applyOverrides({ compaction: { reserveTokens: this.reserve(window) } });
|
|
211
|
+
}
|
|
212
|
+
/** The instance's reserve is read once under a cap, before the first override replaces it. */
|
|
213
|
+
reserve(window) {
|
|
214
|
+
const settings = this.pi.settingsManager;
|
|
215
|
+
if (this.cap === undefined)
|
|
216
|
+
return settings.getCompactionSettings().reserveTokens;
|
|
217
|
+
this.instanceReserve ??= settings.getCompactionSettings().reserveTokens;
|
|
218
|
+
return Math.max(window - this.cap, this.instanceReserve);
|
|
219
|
+
}
|
|
220
|
+
skills() {
|
|
221
|
+
return this.pi.resourceLoader.getSkills().skills.map(({ name, description }) => ({ name, description }));
|
|
222
|
+
}
|
|
186
223
|
async pendingQueue() {
|
|
187
224
|
return {
|
|
188
225
|
steering: [...this.pi.getSteeringMessages()],
|
|
@@ -209,12 +246,11 @@ export class PiSession {
|
|
|
209
246
|
}
|
|
210
247
|
async history() {
|
|
211
248
|
this.live();
|
|
212
|
-
return toChatTurns(this.pi.
|
|
249
|
+
return toChatTurns(branchMessages(this.pi.sessionManager));
|
|
213
250
|
}
|
|
214
251
|
async rewindToUserTurn(index) {
|
|
215
252
|
const total = (await this.history()).filter((t) => t.role === "user").length;
|
|
216
|
-
//
|
|
217
|
-
// shows, so only end-relative indices line up.
|
|
253
|
+
// End-relative: a user entry toChatTurns would not count cannot shift the target.
|
|
218
254
|
const back = total - index;
|
|
219
255
|
const users = this.pi.sessionManager
|
|
220
256
|
.getBranch()
|
|
@@ -227,49 +263,41 @@ export class PiSession {
|
|
|
227
263
|
if (cancelled)
|
|
228
264
|
throw new Error("rewind cancelled");
|
|
229
265
|
}
|
|
230
|
-
/** Pi keeps no lock of its own: a second `compact()` summarizes a transcript
|
|
231
|
-
* being replaced under it, and two POSTs a millisecond apart both pass the
|
|
232
|
-
* route's idle check. */
|
|
233
|
-
compacting = null;
|
|
234
|
-
async compact() {
|
|
235
|
-
this.live();
|
|
236
|
-
if (this.compacting)
|
|
237
|
-
throw new Error(`session ${this.pi.sessionId} is already compacting`);
|
|
238
|
-
// Recorded in the same tick, no await between: that is what makes the check a gate.
|
|
239
|
-
const running = this.pi.compact().then(() => undefined);
|
|
240
|
-
this.compacting = running;
|
|
241
|
-
try {
|
|
242
|
-
await running;
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
this.compacting = null;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
/** An append: Pi's reader takes the latest `session_info`. Never refused for
|
|
249
|
-
* being busy. TODO: renaming a cold session costs a whole resume for one
|
|
250
|
-
* appended line; revisit when Pi offers a lightweight append. */
|
|
251
|
-
async rename(name) {
|
|
252
|
-
this.live();
|
|
253
|
-
this.pi.sessionManager.appendSessionInfo(name);
|
|
254
|
-
this.wrote();
|
|
255
|
-
}
|
|
256
|
-
/** A dispatch landing mid-compaction waits for the summary instead of
|
|
257
|
-
* starting a turn over it. Not Pi's follow-up queue: that is drained only by
|
|
258
|
-
* the *next* turn, so a message parked there while idle would sit unsent. */
|
|
259
|
-
async whenCompacted() {
|
|
260
|
-
while (this.compacting)
|
|
261
|
-
await this.compacting.catch(() => undefined);
|
|
262
|
-
}
|
|
263
266
|
// Async, so a refusal is a rejected promise the seam lets callers `.catch()`.
|
|
264
267
|
async prompt(text) {
|
|
265
|
-
this.live();
|
|
266
|
-
await this.whenCompacted();
|
|
267
|
-
// The wait above is long enough for a dispose to land.
|
|
268
268
|
this.live();
|
|
269
269
|
// A turn may have started since the caller read the state. Bare, Pi throws
|
|
270
270
|
// "already processing" and the message is gone (§5); queued, it is the
|
|
271
271
|
// same "delivered when idle" core/queue.ts picks for a mid-turn message.
|
|
272
|
-
|
|
272
|
+
let refused = false;
|
|
273
|
+
try {
|
|
274
|
+
await this.pi.prompt(text, { streamingBehavior: "followUp", preflightResult: (ok) => { refused = !ok; } });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (refused)
|
|
278
|
+
this.recordRefusal(text, error);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Pi refuses before writing anything (no model, no key), so without this the
|
|
283
|
+
* message and its reason would exist only as a live event (§5). Recorded the
|
|
284
|
+
* way a provider failure is — an errored reply the model's context drops. */
|
|
285
|
+
recordRefusal(text, error) {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
const manager = this.pi.sessionManager;
|
|
288
|
+
manager.appendMessage({ role: "user", content: [{ type: "text", text }], timestamp: now });
|
|
289
|
+
manager.appendMessage({
|
|
290
|
+
role: "assistant",
|
|
291
|
+
content: [],
|
|
292
|
+
api: this.pi.model?.api ?? "",
|
|
293
|
+
provider: this.pi.model?.provider ?? "",
|
|
294
|
+
model: this.pi.model?.id ?? "",
|
|
295
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
296
|
+
stopReason: "error",
|
|
297
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
298
|
+
timestamp: now,
|
|
299
|
+
});
|
|
300
|
+
this.pi.agent.state.messages = manager.buildSessionProjection().messages;
|
|
273
301
|
}
|
|
274
302
|
async steer(text) {
|
|
275
303
|
this.live();
|
|
@@ -281,15 +309,13 @@ export class PiSession {
|
|
|
281
309
|
}
|
|
282
310
|
async systemInput(text, origin, mode) {
|
|
283
311
|
this.live();
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
this.live();
|
|
287
|
-
// Read after the wait: a running turn means the call below queues.
|
|
288
|
-
const queued = this.pi.isStreaming;
|
|
312
|
+
// A running turn means the call below queues.
|
|
313
|
+
const queued = this.pi.isStreaming && mode !== "append";
|
|
289
314
|
if (queued)
|
|
290
315
|
this.queuedInputs.push(origin);
|
|
291
316
|
try {
|
|
292
|
-
|
|
317
|
+
// Without a turn Pi appends it now, or after a running turn's tool results.
|
|
318
|
+
return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: mode !== "append", deliverAs: mode === "prompt" || mode === "append" ? undefined : mode });
|
|
293
319
|
}
|
|
294
320
|
catch (error) {
|
|
295
321
|
// Refused: nothing is in flight. The entry may be gone already.
|
|
@@ -338,8 +364,7 @@ export class PiSession {
|
|
|
338
364
|
if (!first.trim())
|
|
339
365
|
return;
|
|
340
366
|
void suggest(first, reply).then((title) => {
|
|
341
|
-
|
|
342
|
-
if (this.disposed || this.pi.sessionManager.getSessionName())
|
|
367
|
+
if (this.disposed)
|
|
343
368
|
return;
|
|
344
369
|
this.pi.sessionManager.appendSessionInfo(title);
|
|
345
370
|
this.wrote();
|
|
@@ -371,6 +396,7 @@ export class PiAgentFactory {
|
|
|
371
396
|
pier;
|
|
372
397
|
titleModel;
|
|
373
398
|
listings;
|
|
399
|
+
roleOf;
|
|
374
400
|
constructor(
|
|
375
401
|
/** Read per session open, so a Console change reaches the next session
|
|
376
402
|
* without a restart; appended as a context file so the user's own
|
|
@@ -384,7 +410,9 @@ export class PiAgentFactory {
|
|
|
384
410
|
/** The built-in `pier` package's one switch list: Pier's own skills switched off. */
|
|
385
411
|
pier = () => ({ skillsOff: [] }), titleModel = () => undefined,
|
|
386
412
|
/** Injected so a test needs no session directory or database. */
|
|
387
|
-
listings = new IndexedListing()
|
|
413
|
+
listings = new IndexedListing(),
|
|
414
|
+
/** A reopened session's role, which only its runs record (tasks/). */
|
|
415
|
+
roleOf = () => undefined) {
|
|
388
416
|
this.instructions = instructions;
|
|
389
417
|
this.skillPaths = skillPaths;
|
|
390
418
|
this.credentials = credentials;
|
|
@@ -393,6 +421,7 @@ export class PiAgentFactory {
|
|
|
393
421
|
this.pier = pier;
|
|
394
422
|
this.titleModel = titleModel;
|
|
395
423
|
this.listings = listings;
|
|
424
|
+
this.roleOf = roleOf;
|
|
396
425
|
}
|
|
397
426
|
/** Catalogs are global, not per session. */
|
|
398
427
|
catalog;
|
|
@@ -583,8 +612,9 @@ export class PiAgentFactory {
|
|
|
583
612
|
const modelRegistry = new ModelRegistry(await this.refreshedRuntime());
|
|
584
613
|
return { modelRegistry, model: active && modelRegistry.find(active.provider, active.id) };
|
|
585
614
|
}
|
|
586
|
-
async resourceLoader(cwd) {
|
|
587
|
-
|
|
615
|
+
async resourceLoader(cwd, role, dispatcher) {
|
|
616
|
+
// A worker never delegates (tasks/operations.ts refuses it), so it is not taught how.
|
|
617
|
+
const skillsOff = role === "worker" ? [...this.pier().skillsOff, "pier-tasks"] : this.pier().skillsOff;
|
|
588
618
|
const loader = new DefaultResourceLoader({
|
|
589
619
|
cwd,
|
|
590
620
|
agentDir: defaultAgentDir(),
|
|
@@ -601,19 +631,27 @@ export class PiAgentFactory {
|
|
|
601
631
|
agentsFilesOverride: (current) => {
|
|
602
632
|
const content = this.instructions();
|
|
603
633
|
return {
|
|
604
|
-
agentsFiles:
|
|
605
|
-
|
|
606
|
-
:
|
|
634
|
+
agentsFiles: [
|
|
635
|
+
...current.agentsFiles,
|
|
636
|
+
...(content ? [{ path: "<pier>/AGENTS.md", content }] : []),
|
|
637
|
+
...(dispatcher ? [{ path: "<pier>/dispatcher.md", content: DISPATCHER }] : []),
|
|
638
|
+
...(role === "lead" ? [{ path: "<pier>/lead.md", content: LEAD }] : []),
|
|
639
|
+
],
|
|
607
640
|
};
|
|
608
641
|
},
|
|
609
642
|
});
|
|
610
643
|
await loader.reload();
|
|
611
644
|
return loader;
|
|
612
645
|
}
|
|
613
|
-
open(
|
|
614
|
-
return this.providerConfig.withWrite(() => this.openSnapshot(
|
|
646
|
+
open(sessionManager, opts) {
|
|
647
|
+
return this.providerConfig.withWrite(() => this.openSnapshot(sessionManager, opts));
|
|
615
648
|
}
|
|
616
|
-
async openSnapshot(
|
|
649
|
+
async openSnapshot(sessionManager, opts) {
|
|
650
|
+
const { cwd, role } = opts;
|
|
651
|
+
// The home is where the continuous conversation's sessions run: only they
|
|
652
|
+
// dispatch, and they compact at main's cap.
|
|
653
|
+
const main = realPath(cwd) === realPath(pierPath("home"));
|
|
654
|
+
const cap = main ? MAIN_COMPACTION_CAP : role ? CHILD_COMPACTION_CAP : undefined;
|
|
617
655
|
// A locked store is a refusal with a reason here, not "provider not
|
|
618
656
|
// configured" later. Before appendSessionInfo, so nothing is written.
|
|
619
657
|
await this.credentials?.assertUnlocked();
|
|
@@ -630,7 +668,7 @@ export class PiAgentFactory {
|
|
|
630
668
|
cwd,
|
|
631
669
|
sessionManager,
|
|
632
670
|
modelRuntime: runtime,
|
|
633
|
-
resourceLoader: await this.resourceLoader(cwd),
|
|
671
|
+
resourceLoader: await this.resourceLoader(cwd, role, main),
|
|
634
672
|
});
|
|
635
673
|
const live = created.session;
|
|
636
674
|
// Pi defaults to one follow-up per turn boundary, so N queued messages cost
|
|
@@ -642,7 +680,7 @@ export class PiAgentFactory {
|
|
|
642
680
|
}, retention, () => {
|
|
643
681
|
const model = this.titleModel();
|
|
644
682
|
return model && ((first, reply) => this.suggestTitle(model, first, reply));
|
|
645
|
-
});
|
|
683
|
+
}, cap);
|
|
646
684
|
if (opts.model)
|
|
647
685
|
await session.setModel(opts.model);
|
|
648
686
|
if (opts.thinking)
|
|
@@ -654,13 +692,14 @@ export class PiAgentFactory {
|
|
|
654
692
|
this.listing = undefined;
|
|
655
693
|
// Resolved before Pi records it, so every later listing names it the same way.
|
|
656
694
|
const cwd = realPath(opts.cwd);
|
|
657
|
-
return this.open(
|
|
695
|
+
return this.open(SessionManager.create(cwd), { ...opts, cwd });
|
|
658
696
|
}
|
|
659
697
|
async resume(sessionId) {
|
|
698
|
+
const role = this.roleOf(sessionId);
|
|
660
699
|
const known = this.located.get(sessionId);
|
|
661
700
|
if (known) {
|
|
662
701
|
try {
|
|
663
|
-
return await this.open(
|
|
702
|
+
return await this.open(SessionManager.open(known.path), { cwd: known.cwd, role });
|
|
664
703
|
}
|
|
665
704
|
catch (err) {
|
|
666
705
|
log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
|
|
@@ -670,7 +709,7 @@ export class PiAgentFactory {
|
|
|
670
709
|
const info = await this.locate(sessionId);
|
|
671
710
|
if (!info)
|
|
672
711
|
throw new Error(`unknown session: ${sessionId}`);
|
|
673
|
-
return this.open(info.cwd || process.cwd(),
|
|
712
|
+
return this.open(SessionManager.open(info.path), { cwd: info.cwd || process.cwd(), role });
|
|
674
713
|
}
|
|
675
714
|
/** The one place "no such session" is decided. A retained listing is not
|
|
676
715
|
* evidence a session is gone, and callers read a miss as permission to start
|
|
@@ -681,6 +720,10 @@ export class PiAgentFactory {
|
|
|
681
720
|
return find(await this.listed()) ??
|
|
682
721
|
(reused && this.listing === reused ? find(await this.listed(true)) : undefined);
|
|
683
722
|
}
|
|
723
|
+
async readHistory(sessionId) {
|
|
724
|
+
const info = await this.locate(sessionId);
|
|
725
|
+
return info && toChatTurns(branchMessages(SessionManager.open(info.path)));
|
|
726
|
+
}
|
|
684
727
|
async find(sessionId) {
|
|
685
728
|
const info = await this.locate(sessionId);
|
|
686
729
|
return info ? summaryOf(info) : undefined;
|
|
@@ -714,8 +757,8 @@ export class PiAgentFactory {
|
|
|
714
757
|
return (await this.listed()).map(summaryOf);
|
|
715
758
|
}
|
|
716
759
|
/** After a listing, so a transcript that grew is indexed before it is asked about. */
|
|
717
|
-
async search(query) {
|
|
760
|
+
async search(query, limit) {
|
|
718
761
|
await this.listed();
|
|
719
|
-
return this.listings.search?.(query) ?? [];
|
|
762
|
+
return this.listings.search?.(query, limit) ?? [];
|
|
720
763
|
}
|
|
721
764
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The role contracts Pier injects from code, never written to disk
|
|
2
|
+
// (docs/design/10-continuous-session.md): the dispatcher's, for the main
|
|
3
|
+
// session of the continuous conversation, the feature lead's, and the chat
|
|
4
|
+
// surface's, which every session gets.
|
|
5
|
+
export const DISPATCHER = `# You are the main session of Pier's continuous conversation
|
|
6
|
+
|
|
7
|
+
The user talks to Pier as one conversation; you are its current session, in the home directory, which holds memory only. You answer, remember and dispatch. You never edit code or files outside this directory yourself.
|
|
8
|
+
|
|
9
|
+
## Dispatch
|
|
10
|
+
- Real work is a child run: \`pier task run --prompt … --cwd <dir> --model hardest|balanced|cheap [--timeout <s>]\` (skills/pier-tasks). The model is a tier the operator pinned: \`hardest\` for a lead, design, architecture; \`balanced\` for coding a feature or a fix, integration; \`cheap\` for research, summaries, lookups, transcripts, bulk mechanical edits. A tier follows the change's difficulty, not the task's kind: a review takes the builder's tier, \`hardest\` only when the diff touches a seam (core/channels/tasks \`types.ts\`, \`db.ts\` migrations, auth/vault/secrets) or the builder's result reports a risk or an unverified part; a model the user names overrides both. Thinking follows the pin: pass \`--thinking\` only to override it; never \`--model ?\` per message. One \`wt\` worktree per feature: \`wt switch -c <branch> --no-cd -y --format json\` in the repo, its \`.path\` as \`--cwd\`.
|
|
11
|
+
- A small, clear task is a worker: one run, one worktree. Larger work is a lead: \`pier task run --role lead --prompt … --cwd <its worktree> --model hardest --thinking high\`; it builds with its own workers and reports milestones, one callback per wave, never one per worker. Only a product or architecture design the user finalizes adds \`--design\`, which tags it design: the user designs with the lead in its own session, and you are not in that path. Any other lead — a build, a plan it builds itself, a review — has no \`--design\` and is tagged build.
|
|
12
|
+
- Before the first tool call on a message, decide: answer from what is in context, or dispatch. One command may answer; a second command means a worker.
|
|
13
|
+
- Every new run carries \`--name "<a few words>"\` that hit its intent — the session's title in the user's language, no role word (the web status panel tags a lead design or build itself).
|
|
14
|
+
- Only the user finalizes a design: the lead asks them, and its milestone \`Design final: <path>\` means they confirmed. That milestone, or the user telling you to build a design, starts the build in a NEW lead, never the design lead continued: \`pier task run --role lead --thinking medium --cwd <the lead's worktree> --model hardest --name "…" --prompt "Build per <path>: …"\` — no \`--design\`, the lead's model, never a new pick. Never start a build on a design the user has not confirmed.
|
|
15
|
+
- A follow-up on a feature continues its child — \`--run <id>\`, or \`--session <id>\` once idle — never a new one. Pass the user's words verbatim, your additions after them; never re-summarize.
|
|
16
|
+
- Say in your reply what you dispatched, then end your turn: callbacks are the only delivery. A callback's text is on the surface the user reads: the reply says what it means and what is next, never repeats it.
|
|
17
|
+
- \`pier task runs\` lists the runs this conversation launched (in flight, and finished in the last 24h) — for orientation, never for waiting.
|
|
18
|
+
|
|
19
|
+
## Memory
|
|
20
|
+
- \`MEMORY.md\`: durable facts, decisions, the project index (repo → path, worktree convention). \`memory/YYYY-MM-DD.md\`: daily notes, local date.
|
|
21
|
+
- MEMORY.md is re-read in full at every session open: a list of facts, one line each. Never record what this contract, AGENTS.md or a skill already says.
|
|
22
|
+
- Edit MEMORY.md in place: a decision that supersedes another replaces it, no history kept.
|
|
23
|
+
- A daily-note line holds only a decision (what + one clause why) or a fact git, the ledger and transcripts do not hold: a live-verified result, a user preference, a flaky test, a manual step the user owes. One line, ~40 Chinese chars / 25 words, keywords, no narration; a changed decision edits its line, never appends; a durable one goes to MEMORY.md, not the note.
|
|
24
|
+
- Never noted: dispatches, run ids, merges, commit hashes, test counts, restarts — git log, \`pier task runs\` and transcripts hold them; read them on demand. Repo knowledge belongs in that repo's own AGENTS.md, written by a child.
|
|
25
|
+
- Recall is files plus transcripts: \`rg\` over \`memory/\`, \`pier search <words>\` over the earlier sessions (skills/pier-search).
|
|
26
|
+
- A new session of this conversation opens with a seed: MEMORY.md, the open items, the run ledger, today's and yesterday's notes, and the previous session's last exchanges.
|
|
27
|
+
|
|
28
|
+
## Open items
|
|
29
|
+
- The list of what this conversation is solving is yours, written inside your reply and stripped from what the user sees: \`<open>problem — stage (run <id>)</open>\` adds or replaces the item with that problem, \`<done>problem</done>\` removes it. The problem is the user's words, the same every time (it is the key); the stage is where it stands (\`worker running\`, \`merged, restart pending\`, \`waiting on you: 60K or 80K?\`); one \`(run <id>)\` per run behind it, or none.
|
|
30
|
+
- An open item is work in flight or waiting on the user's decision now; backlog and ideas go in MEMORY.md, never here.
|
|
31
|
+
- Write one on dispatch, and on every callback and decision that moves a stage; \`<done>\` when the run finishes and nothing awaits the user, the daily note holding what was decided.
|
|
32
|
+
- The user sees the list with \`/status\`; a stale stage there is fixed with another marker.`;
|
|
33
|
+
export const LEAD = `# You are a feature lead
|
|
34
|
+
|
|
35
|
+
You own one feature, in this worktree. The design doc you keep here is the state: anything not in it is lost when your session ends.
|
|
36
|
+
|
|
37
|
+
## Design
|
|
38
|
+
- Only when your run is a design discussion the user finalizes; any other lead goes straight to §Build.
|
|
39
|
+
- Work the design out with the user, who talks to you directly in this session. Write it to a doc in this worktree and keep it current.
|
|
40
|
+
- Only the user declares it final. When you think it is ready, ask whether to finalize, offering it as a next-step button (\`[Finalize design]\`); the question never carries the \`Design final:\` line.
|
|
41
|
+
- Once the user confirms, end your reply with \`Design final: <absolute path of the doc>\` and stop: a new lead builds it, launched by your supervisor from that line or when the user says to build. Do not start building here.
|
|
42
|
+
|
|
43
|
+
## Build
|
|
44
|
+
- Started to build per a doc: read it first; it is the whole state. Started on a task with no doc: plan it in one here and build it; a plan that needs the user's OK is a question in your reply, never a \`Design final:\`.
|
|
45
|
+
- Decompose it into worker runs: \`pier task run --name "<a few words>" --prompt … --cwd <worker worktree>\`, one \`wt\` worktree each (\`wt switch -c <branch> --no-cd -y --format json\` in the repo). The prompt is the worker's whole handoff; a worker never delegates.
|
|
46
|
+
- Workers run on \`--model balanced\` for code, \`--model cheap\` for research and mechanical work.
|
|
47
|
+
- Never launch another lead (\`--role lead\` is refused).
|
|
48
|
+
- Each worker's result comes back to you: review it and integrate its branch here. While other results are still owed you, your replies reach only this session; your reply to the last one is the milestone your supervisor reads — what is done, what is next, any decision you need.
|
|
49
|
+
- The build is yours to declare done, never the user's to confirm: a reply that leaves nothing owed you, workers or none, is that milestone.
|
|
50
|
+
- \`pier task runs\` lists the runs you launched, for orientation, never for waiting.`;
|
|
51
|
+
/** The surface contract handed to every agent Pier launches (main.ts); the
|
|
52
|
+
* syntax it tells the agent to emit is parsed back by core/reply.ts. */
|
|
53
|
+
const REPLY_SURFACE_PROMPT = `## Pier chat surface
|
|
54
|
+
|
|
55
|
+
Your replies render in a chat UI (web and IM). Three optional markdown
|
|
56
|
+
conventions:
|
|
57
|
+
|
|
58
|
+
- **Next-step buttons** — a last line of \`---\`, then up to 5 \`[label]\` tokens
|
|
59
|
+
separated by \`|\`: \`---\` / \`[Run it] | [Show the diff]\`. A click sends that
|
|
60
|
+
label as the user's next message. Only for short, obvious next moves, never
|
|
61
|
+
for anything destructive.
|
|
62
|
+
- **Attachments** — link a file you produced by absolute \`file://\` URL:
|
|
63
|
+
\`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
|
|
64
|
+
other files as a download card, wherever on disk you wrote it. The same
|
|
65
|
+
convention runs inbound: a user message ending in \`[name](file:///…)\`
|
|
66
|
+
lines is carrying files the sender attached, already saved to disk — read
|
|
67
|
+
one only when it matters to the task; every read puts its content in your
|
|
68
|
+
context for good.
|
|
69
|
+
- **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
|
|
70
|
+
remains no message is sent. In a group chat you are handed every message,
|
|
71
|
+
including humans talking to each other: stay silent rather than acknowledge
|
|
72
|
+
what was not addressed to you.
|
|
73
|
+
|
|
74
|
+
A message may start with \`[name<id> time place]\` — the sender and the chat,
|
|
75
|
+
added by Pier, not typed by them. It appears only on a change — new speaker, a
|
|
76
|
+
~10-minute gap, a new day — so the last one still applies; a gap alone shows as
|
|
77
|
+
time only, like \`[14:23]\`. Use that \`id\` to mention someone; never ask for
|
|
78
|
+
their own. \`place\` is \`<platform>:<conversation>\` (Slack:
|
|
79
|
+
\`slack:<channel>/<thread_ts>\`), said once per session: the channel and thread a
|
|
80
|
+
script takes. Where no tool of yours takes that platform's ids, the header
|
|
81
|
+
carries neither and reads \`[name time platform]\`. A last \`lang=zh\` (or
|
|
82
|
+
\`en\`, \`ja\`, …) means the sender switched to that language: reply in it
|
|
83
|
+
until another one appears, whatever language the context around it is in.
|
|
84
|
+
`;
|
|
85
|
+
/** Deployment facts an agent cannot discover: a guessed path is wrong wherever
|
|
86
|
+
* `PIER_HOME` moved and fails as "nothing is configured"; GPT models carry
|
|
87
|
+
* `apply_patch` from post-training and go hunting for it in the shell. */
|
|
88
|
+
export function surfacePrompt(instance) {
|
|
89
|
+
const reach = instance.publicUrl
|
|
90
|
+
? `Address: ${instance.publicUrl} — a board's link is that plus ` +
|
|
91
|
+
"`/boards/<slug>/`, or `/p/<slug>-<token>/` once published, where `token` " +
|
|
92
|
+
"is the random field the manifest carries beside `public`."
|
|
93
|
+
: "No public address is configured (the user sets one in Console → Settings), " +
|
|
94
|
+
"so give paths and never guess a host.";
|
|
95
|
+
return `${REPLY_SURFACE_PROMPT}
|
|
96
|
+
## This Pier instance
|
|
97
|
+
|
|
98
|
+
Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
|
|
99
|
+
|
|
100
|
+
Editing: files change through the \`edit\` tool (exact text replacement) or
|
|
101
|
+
\`write\`. There is no \`apply_patch\` here — not as a tool, not as a command —
|
|
102
|
+
so do not call one or go looking for one in the shell.
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The Pi-config seams the Console asks agent/ for, declared by the side that
|
|
2
|
+
// answers them. Imports no SDK and no node:* — web/ui bundles it type-only —
|
|
3
|
+
// so any area may import it; keep it implementable over RPC.
|
|
4
|
+
/** The global files the configuration document carries (`web/config-sync.ts`):
|
|
5
|
+
* the three whole, settings.json only its two defaults. */
|
|
6
|
+
export const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
|
|
7
|
+
export class PackageError extends Error {
|
|
8
|
+
reason;
|
|
9
|
+
constructor(reason, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
|
|
15
|
+
// non-Pi backend is bound to them by this seam.
|
|
16
|
+
const PROVIDER_APIS = [
|
|
17
|
+
"openai-completions",
|
|
18
|
+
"openai-responses",
|
|
19
|
+
"anthropic-messages",
|
|
20
|
+
"google-generative-ai",
|
|
21
|
+
];
|
|
22
|
+
export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
|
|
23
|
+
/** How far a model's reasoning goes. Every reasoning model offers up to
|
|
24
|
+
* "high"; the two levels above it exist only for a model whose catalog entry
|
|
25
|
+
* says so, which is the one thing a Console-defined model could not say. */
|
|
26
|
+
export const MODEL_EFFORTS = ["high", "xhigh", "max"];
|
|
27
|
+
/** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
|
|
28
|
+
* write and web/ pre-checks them at its HTTP boundary. Throws the message the
|
|
29
|
+
* surface shows. */
|
|
30
|
+
export function validateProviderSetup(input) {
|
|
31
|
+
if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
|
|
32
|
+
throw new Error("invalid provider id");
|
|
33
|
+
}
|
|
34
|
+
if (input.endpoint) {
|
|
35
|
+
if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
|
|
36
|
+
throw new Error("invalid endpoint");
|
|
37
|
+
}
|
|
38
|
+
validateEndpoint(input.endpoint);
|
|
39
|
+
}
|
|
40
|
+
if (input.kind === "builtin")
|
|
41
|
+
return;
|
|
42
|
+
if (!input.endpoint)
|
|
43
|
+
throw new Error("custom provider endpoint required");
|
|
44
|
+
if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
|
|
45
|
+
throw new Error("invalid provider name");
|
|
46
|
+
}
|
|
47
|
+
if (!isProviderApi(input.api))
|
|
48
|
+
throw new Error("unsupported provider API");
|
|
49
|
+
if (!input.models.length || input.models.length > 100)
|
|
50
|
+
throw new Error("1-100 models required");
|
|
51
|
+
const ids = input.models.map((model) => model.id);
|
|
52
|
+
if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
|
|
53
|
+
throw new Error("model ids must be non-empty, trimmed and unique");
|
|
54
|
+
}
|
|
55
|
+
// An effort ceiling on a model that does not reason would be written into
|
|
56
|
+
// the catalog and never offered — a setting that lies about itself.
|
|
57
|
+
if (input.models.some((model) => model.effort !== undefined && !model.reasoning)) {
|
|
58
|
+
throw new Error("effort requires reasoning");
|
|
59
|
+
}
|
|
60
|
+
if (input.models.some((model) => model.effort !== undefined && !MODEL_EFFORTS.includes(model.effort))) {
|
|
61
|
+
throw new Error("unsupported model effort");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function validateEndpoint(endpoint) {
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(endpoint);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error("endpoint must be an http(s) URL");
|
|
71
|
+
}
|
|
72
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
73
|
+
throw new Error("endpoint must be an http(s) URL");
|
|
74
|
+
}
|
|
75
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
76
|
+
throw new Error("endpoint must not contain credentials, query or fragment");
|
|
77
|
+
}
|
|
78
|
+
}
|