@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/core/router.js
CHANGED
|
@@ -1,46 +1,23 @@
|
|
|
1
1
|
// Conversation → session routing plus event wiring. In-memory on purpose:
|
|
2
2
|
// the durable chat → session map lives in channels/conversations.ts.
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
3
|
import { logger } from "../log.js";
|
|
5
4
|
import { EventHub } from "./hub.js";
|
|
6
5
|
import { SenderPrefix, withPrefix } from "./identity.js";
|
|
7
6
|
import { decide } from "./queue.js";
|
|
8
|
-
import { splitReply } from "./reply.js";
|
|
7
|
+
import { cut, splitReply } from "./reply.js";
|
|
8
|
+
import { isChatCommand } from "./types.js";
|
|
9
9
|
const log = logger("core");
|
|
10
10
|
/** Generous: eviction is a memory measure, and re-opening costs a Pi resume
|
|
11
11
|
* plus a transcript read. */
|
|
12
12
|
const IDLE_TTL_MS = 30 * 60_000;
|
|
13
13
|
const SWEEP_MS = 5 * 60_000;
|
|
14
|
-
/** An error goes into a chat window, so it is trimmed to something readable. */
|
|
15
|
-
const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
|
|
16
|
-
/** What a chat window gets of a system input; a task callback carries up to
|
|
17
|
-
* 8000 characters of result text (tasks/callbacks.ts). */
|
|
18
|
-
const NOTE_CHARS = 200;
|
|
19
|
-
const NOTE_LINES = 4;
|
|
20
|
-
/** A note is context for the turn it precedes, not the message: pasted whole,
|
|
21
|
-
* a run result buries the chat on IM, which cannot collapse it. The hub and
|
|
22
|
-
* the transcript keep every character. */
|
|
23
|
-
function digest(text) {
|
|
24
|
-
const body = text.trimEnd();
|
|
25
|
-
let head = body.split("\n").slice(0, NOTE_LINES).join("\n");
|
|
26
|
-
if (head.length > NOTE_CHARS) {
|
|
27
|
-
const capped = head.slice(0, NOTE_CHARS);
|
|
28
|
-
// A boundary before the midpoint loses more than the ragged edge costs.
|
|
29
|
-
const boundary = Math.max(capped.lastIndexOf("\n"), capped.lastIndexOf(" "));
|
|
30
|
-
head = capped.slice(0, boundary > NOTE_CHARS / 2 ? boundary : NOTE_CHARS);
|
|
31
|
-
}
|
|
32
|
-
const rest = body.slice(head.length).trim();
|
|
33
|
-
if (!rest)
|
|
34
|
-
return body;
|
|
35
|
-
const dropped = rest.split("\n").length;
|
|
36
|
-
return `${head.trimEnd()}\n… +${String(dropped)} more line${dropped === 1 ? "" : "s"}`;
|
|
37
|
-
}
|
|
38
14
|
function keyOf(key) {
|
|
39
15
|
return `${key.channelId}:${key.conversationId}`;
|
|
40
16
|
}
|
|
41
17
|
/** `web:<id>` and `task:<id>` name one session id and neither is a chat — no
|
|
42
18
|
* Channel is registered under them — so they share a lock in `ensure`. */
|
|
43
19
|
const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
|
|
20
|
+
const webKey = (sessionId) => ({ channelId: "web", conversationId: sessionId });
|
|
44
21
|
export class QueueOperationError extends Error {
|
|
45
22
|
reason;
|
|
46
23
|
constructor(reason, message) {
|
|
@@ -48,6 +25,28 @@ export class QueueOperationError extends Error {
|
|
|
48
25
|
this.reason = reason;
|
|
49
26
|
}
|
|
50
27
|
}
|
|
28
|
+
/** A message the router did not take and has already told the chat about
|
|
29
|
+
* (`refuse`): a caller that reports its own failures skips these. */
|
|
30
|
+
export class Refused extends Error {
|
|
31
|
+
}
|
|
32
|
+
/** A short skill spelling that names more than one skill: not sent, the chat told. */
|
|
33
|
+
export class SkillAmbiguous extends Refused {
|
|
34
|
+
}
|
|
35
|
+
/** `/<word> <rest>` or `%<word> <rest>` (also `/skill:<word>`) resolved to the
|
|
36
|
+
* one skill `word` is a prefix of — of the name, or of the name after any `-`;
|
|
37
|
+
* the text unchanged when it names none (docs/design/11-im-conversation.md). */
|
|
38
|
+
function skillText(text, skills) {
|
|
39
|
+
// Two characters at least: `/s <text>` is the settings draft's spelling (channels/commands.ts).
|
|
40
|
+
const [, kept, spelled, rest] = /^[/%](skill:)?([^\s/%]{2,})(?:[ \t]+([\s\S]*))?$/i.exec(text.trim()) ?? [];
|
|
41
|
+
const word = spelled?.toLowerCase();
|
|
42
|
+
if (!word || (!kept && isChatCommand(word)))
|
|
43
|
+
return text;
|
|
44
|
+
const names = skills.map((s) => s.name);
|
|
45
|
+
const found = names.includes(word) ? [word] : names.filter((name) => name.split("-").some((_, i, parts) => parts.slice(i).join("-").startsWith(word)));
|
|
46
|
+
if (found.length > 1)
|
|
47
|
+
return new SkillAmbiguous(`/${word} matches ${found.join(", ")} — say more`);
|
|
48
|
+
return found[0] ? `/skill:${found[0]}${rest ? ` ${rest}` : ""}` : text;
|
|
49
|
+
}
|
|
51
50
|
export class Router {
|
|
52
51
|
hub;
|
|
53
52
|
resolve;
|
|
@@ -64,7 +63,7 @@ export class Router {
|
|
|
64
63
|
/** Set by a graceful restart (src/drain.ts); `endDrain` is for the caller
|
|
65
64
|
* that drains speculatively and may not get to exit. */
|
|
66
65
|
draining = false;
|
|
67
|
-
|
|
66
|
+
turnEnded;
|
|
68
67
|
constructor(hub,
|
|
69
68
|
/** Create or resume the session owning a conversation (wired in main.ts). */
|
|
70
69
|
resolve,
|
|
@@ -80,14 +79,46 @@ export class Router {
|
|
|
80
79
|
this.sessionIdOf = sessionIdOf;
|
|
81
80
|
this.chatKeyOf = chatKeyOf;
|
|
82
81
|
}
|
|
82
|
+
/** A session its adapter's stop sent back to its own stream takes its chat again. */
|
|
83
83
|
registerChannel(channel) {
|
|
84
84
|
this.channels.set(channel.id, channel);
|
|
85
|
+
for (const attached of this.bySession.values())
|
|
86
|
+
this.toChat(attached);
|
|
87
|
+
}
|
|
88
|
+
/** A stopped adapter's sessions answer on their own stream until it is back:
|
|
89
|
+
* a key on a dead channel delivers nowhere, and Web Push reads it as a chat's. */
|
|
90
|
+
unregisterChannel(channelId) {
|
|
91
|
+
this.channels.delete(channelId);
|
|
92
|
+
for (const key of this.byKey.keys())
|
|
93
|
+
if (key.startsWith(`${channelId}:`))
|
|
94
|
+
this.byKey.delete(key);
|
|
95
|
+
for (const attached of this.bySession.values()) {
|
|
96
|
+
if (attached.key.channelId === channelId)
|
|
97
|
+
attached.key = webKey(attached.session.id);
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
|
-
/**
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
this.
|
|
100
|
+
/** The durable chat outranks the alias that happened to open the session
|
|
101
|
+
* first (a restart, the web speaking first), same rule as `reached`. */
|
|
102
|
+
toChat(attached) {
|
|
103
|
+
const chat = isAlias(attached.key) ? this.chatKeyOf(attached.session.id) : undefined;
|
|
104
|
+
if (!chat || !this.channels.has(chat.channelId))
|
|
105
|
+
return;
|
|
106
|
+
this.hold(chat, attached.session);
|
|
107
|
+
attached.key = chat;
|
|
108
|
+
}
|
|
109
|
+
/** A chat key taken from another session (the chain's rotation) leaves it,
|
|
110
|
+
* or two sessions would answer one chat. */
|
|
111
|
+
hold(key, session) {
|
|
112
|
+
const previous = this.byKey.get(keyOf(key));
|
|
113
|
+
const left = previous && previous !== session ? this.bySession.get(previous.id) : undefined;
|
|
114
|
+
if (left && keyOf(left.key) === keyOf(key))
|
|
115
|
+
left.key = webKey(left.session.id);
|
|
116
|
+
this.byKey.set(keyOf(key), session);
|
|
117
|
+
}
|
|
118
|
+
/** Every attached session's answered turn, runs' and humans' alike; a failed
|
|
119
|
+
* turn is not one. Registered by the task service (tasks/service.ts). */
|
|
120
|
+
onTurnEnd(listener) {
|
|
121
|
+
this.turnEnded = listener;
|
|
91
122
|
}
|
|
92
123
|
/** A failure reaches the chat as well as the hub (§5): on IM, silence is
|
|
93
124
|
* indistinguishable from a crash. `notify`, not `send`, so it is never
|
|
@@ -97,7 +128,7 @@ export class Router {
|
|
|
97
128
|
this.hub.emit(sessionId, { type: "error", message });
|
|
98
129
|
const channel = this.channels.get(key.channelId);
|
|
99
130
|
// Never recursive: if telling the chat also fails, the hub has the original.
|
|
100
|
-
channel?.notify(key.conversationId, { text:
|
|
131
|
+
channel?.notify(key.conversationId, { text: cut(message, 600), origin: { kind: "error" } })
|
|
101
132
|
.catch((err) => {
|
|
102
133
|
log.error(`could not report the failure to ${key.channelId}`, err);
|
|
103
134
|
this.hub.emit(sessionId, {
|
|
@@ -126,7 +157,7 @@ export class Router {
|
|
|
126
157
|
// Snapshot: the loop awaits dispose(), and the map may change meanwhile.
|
|
127
158
|
// oxlint-disable-next-line unicorn/no-useless-spread
|
|
128
159
|
for (const [id, attached] of [...this.bySession]) {
|
|
129
|
-
if (this.queueOperations.has(id) || this.
|
|
160
|
+
if (this.queueOperations.has(id) || this.submitting.has(id))
|
|
130
161
|
continue;
|
|
131
162
|
if (!includeWatched && this.hub.hasSubscribers(id))
|
|
132
163
|
continue;
|
|
@@ -165,9 +196,6 @@ export class Router {
|
|
|
165
196
|
stateOf(sessionId) {
|
|
166
197
|
return this.bySession.get(sessionId)?.session.state;
|
|
167
198
|
}
|
|
168
|
-
stateSinceOf(sessionId) {
|
|
169
|
-
return this.bySession.get(sessionId)?.stateSince;
|
|
170
|
-
}
|
|
171
199
|
/** Current in-memory model of a live session (undefined when not attached). */
|
|
172
200
|
modelOf(sessionId) {
|
|
173
201
|
return this.bySession.get(sessionId)?.session.model;
|
|
@@ -187,7 +215,7 @@ export class Router {
|
|
|
187
215
|
attach(key, session) {
|
|
188
216
|
const existing = this.bySession.get(session.id);
|
|
189
217
|
if (existing?.session === session) {
|
|
190
|
-
this.
|
|
218
|
+
this.hold(key, session);
|
|
191
219
|
this.reached(session, key);
|
|
192
220
|
return;
|
|
193
221
|
}
|
|
@@ -199,14 +227,13 @@ export class Router {
|
|
|
199
227
|
this.forgetKeys(existing.session);
|
|
200
228
|
void existing.session.dispose().catch((err) => log.error(`disposing replaced session ${session.id} failed`, err));
|
|
201
229
|
}
|
|
202
|
-
this.
|
|
230
|
+
this.hold(key, session);
|
|
203
231
|
log.info(`attached ${keyOf(key)} → session ${session.id}`);
|
|
204
232
|
// Delivery reads `attached.key` live: a chat attaching after the workbench
|
|
205
233
|
// opened the session takes over (`reached`), and the closure must follow.
|
|
206
234
|
const attached = {
|
|
207
235
|
session,
|
|
208
236
|
key,
|
|
209
|
-
stateSince: Date.now(),
|
|
210
237
|
activeAt: Date.now(),
|
|
211
238
|
touched: 0,
|
|
212
239
|
unsubscribe: session.subscribe((payload) => {
|
|
@@ -214,7 +241,7 @@ export class Router {
|
|
|
214
241
|
this.hub.emit(session.id, payload);
|
|
215
242
|
if (payload.type === "state") {
|
|
216
243
|
// Every turn passes here, so it also proves liveness to the sweeper.
|
|
217
|
-
attached.
|
|
244
|
+
attached.activeAt = Date.now();
|
|
218
245
|
attached.touched += 1;
|
|
219
246
|
this.hub.emitWorkspace({
|
|
220
247
|
type: "session-state",
|
|
@@ -230,7 +257,7 @@ export class Router {
|
|
|
230
257
|
log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
|
|
231
258
|
const channel = this.channels.get(key.channelId);
|
|
232
259
|
channel?.notify(key.conversationId, {
|
|
233
|
-
text:
|
|
260
|
+
text: cut(payload.message, 600),
|
|
234
261
|
origin: { kind: "error" },
|
|
235
262
|
}).catch((err) => log.error(`notify ${key.channelId} failed`, err));
|
|
236
263
|
}
|
|
@@ -239,7 +266,7 @@ export class Router {
|
|
|
239
266
|
if (payload.type === "system-input") {
|
|
240
267
|
const channel = this.channels.get(key.channelId);
|
|
241
268
|
channel?.notify(key.conversationId, {
|
|
242
|
-
text:
|
|
269
|
+
text: payload.text,
|
|
243
270
|
origin: payload.origin,
|
|
244
271
|
at: payload.at,
|
|
245
272
|
})
|
|
@@ -260,6 +287,8 @@ export class Router {
|
|
|
260
287
|
// Empty text included: adapters retire per-turn UI (👀 receipts) on it.
|
|
261
288
|
if (payload.type === "turn-end") {
|
|
262
289
|
log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
|
|
290
|
+
if (!payload.error)
|
|
291
|
+
this.turnEnded?.(session.id, payload.text);
|
|
263
292
|
const channel = this.channels.get(key.channelId);
|
|
264
293
|
if (channel) {
|
|
265
294
|
const reply = splitReply(payload.text, payload.meta);
|
|
@@ -272,13 +301,7 @@ export class Router {
|
|
|
272
301
|
}),
|
|
273
302
|
};
|
|
274
303
|
this.bySession.set(session.id, attached);
|
|
275
|
-
|
|
276
|
-
// first (a restart, the web speaking first), same rule as `reached`.
|
|
277
|
-
const chat = isAlias(key) ? this.chatKeyOf(session.id) : undefined;
|
|
278
|
-
if (chat) {
|
|
279
|
-
this.byKey.set(keyOf(chat), session);
|
|
280
|
-
attached.key = chat;
|
|
281
|
-
}
|
|
304
|
+
this.toChat(attached);
|
|
282
305
|
}
|
|
283
306
|
/** An adapter's send is several platform calls (chunks, then attachments),
|
|
284
307
|
* so two answers left to overlap interleave in the chat. Per conversation:
|
|
@@ -303,37 +326,12 @@ export class Router {
|
|
|
303
326
|
}
|
|
304
327
|
queueOperations = new Set();
|
|
305
328
|
promotionRequested = new Set();
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
uncertaintyHeld = new Map();
|
|
310
|
-
queueUncertain(sessionId) {
|
|
311
|
-
return this.uncertaintyHeld.has(sessionId);
|
|
312
|
-
}
|
|
313
|
-
recoveryOf(sessionId) {
|
|
314
|
-
return structuredClone(this.recoveries.get(sessionId) ?? []);
|
|
315
|
-
}
|
|
316
|
-
recoveryChanged(sessionId) {
|
|
317
|
-
if (!this.recoveries.get(sessionId)?.length)
|
|
318
|
-
this.recoveries.delete(sessionId);
|
|
319
|
-
this.hub.emit(sessionId, {
|
|
320
|
-
type: "queue-recovery", batches: this.recoveryOf(sessionId), uncertain: this.queueUncertain(sessionId),
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
acknowledgeRecovery(sessionId, batchId) {
|
|
324
|
-
const batches = this.recoveries.get(sessionId);
|
|
325
|
-
const batch = batches?.find((b) => b.id === batchId);
|
|
326
|
-
if (!batch)
|
|
327
|
-
throw new QueueOperationError("missing", "No such recovery batch");
|
|
328
|
-
if (batch.status === "submitting")
|
|
329
|
-
throw new QueueOperationError("busy", "Submission has not settled");
|
|
330
|
-
this.recoveries.set(sessionId, batches.filter((b) => b !== batch));
|
|
331
|
-
this.promotionRequested.delete(sessionId);
|
|
332
|
-
this.recoveryChanged(sessionId);
|
|
333
|
-
}
|
|
329
|
+
// Promotions launched but not settled, per session: automatic promotion
|
|
330
|
+
// waits for them, and eviction must not dispose a preflight.
|
|
331
|
+
submitting = new Map();
|
|
334
332
|
/** Only queue mutation holds this lock; a running turn does not, so manual
|
|
335
333
|
* controls stay available while it runs. */
|
|
336
|
-
async useQueue(sessionId, action, key =
|
|
334
|
+
async useQueue(sessionId, action, key = webKey(sessionId)) {
|
|
337
335
|
if (this.queueOperations.has(sessionId))
|
|
338
336
|
throw new QueueOperationError("busy", "Queue operation in progress");
|
|
339
337
|
this.queueOperations.add(sessionId);
|
|
@@ -347,10 +345,7 @@ export class Router {
|
|
|
347
345
|
}
|
|
348
346
|
async recallQueue(sessionId) {
|
|
349
347
|
return this.useQueue(sessionId, async (session) => {
|
|
350
|
-
const held = this.uncertaintyHeld.get(sessionId);
|
|
351
348
|
const queue = await session.clearQueue();
|
|
352
|
-
if (this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
|
|
353
|
-
this.recoveryChanged(sessionId);
|
|
354
349
|
if (queue.steering.length || queue.followUp.length)
|
|
355
350
|
this.forgetSender(sessionId);
|
|
356
351
|
return queue;
|
|
@@ -360,35 +355,25 @@ export class Router {
|
|
|
360
355
|
if (this.draining)
|
|
361
356
|
throw new QueueOperationError("draining", "Pier is restarting; queued messages were not submitted");
|
|
362
357
|
}
|
|
358
|
+
/** Once cleared, the originals exist only here: a failure hands them back
|
|
359
|
+
* to the conversation with the error, and nothing is kept for a resend. */
|
|
363
360
|
async deliverQueue(sessionId, mode) {
|
|
364
|
-
let
|
|
361
|
+
let cleared = false;
|
|
365
362
|
try {
|
|
366
363
|
this.checkQueueDrain();
|
|
367
364
|
return await this.useQueue(sessionId, async (session) => {
|
|
368
365
|
this.checkQueueDrain();
|
|
369
|
-
if (mode === "auto" &&
|
|
366
|
+
if (mode === "auto" && session.state !== "idle")
|
|
370
367
|
return "";
|
|
371
|
-
const held = this.uncertaintyHeld.get(sessionId);
|
|
372
368
|
const queue = await session.clearQueue();
|
|
373
|
-
if (mode !== "auto" && this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
|
|
374
|
-
this.recoveryChanged(sessionId);
|
|
375
369
|
if (!queue.steering.length && !queue.followUp.length)
|
|
376
370
|
throw new QueueOperationError("empty", "Queue is empty");
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
retained = true;
|
|
380
|
-
this.recoveryChanged(sessionId);
|
|
381
|
-
const text = [...batch.steering, ...batch.followUp].join("\n");
|
|
382
|
-
let invoked = false;
|
|
371
|
+
cleared = true;
|
|
372
|
+
const text = [...queue.steering, ...queue.followUp].join("\n");
|
|
383
373
|
const failed = (err) => {
|
|
384
374
|
this.forgetSender(sessionId);
|
|
385
375
|
this.promotionRequested.delete(sessionId);
|
|
386
|
-
|
|
387
|
-
if (invoked)
|
|
388
|
-
this.uncertaintyHeld.set(sessionId, batch.id);
|
|
389
|
-
batch.error = String(err);
|
|
390
|
-
this.recoveryChanged(sessionId);
|
|
391
|
-
this.reportTo(sessionId, `Queue promotion failed (${batch.status}); automatic queue paused, originals remain available in queue recovery: ${String(err)}`);
|
|
376
|
+
this.reportTo(sessionId, `Queued messages were not delivered — send them again: ${String(err)}\n\n${text}`);
|
|
392
377
|
};
|
|
393
378
|
try {
|
|
394
379
|
this.checkQueueDrain();
|
|
@@ -397,13 +382,17 @@ export class Router {
|
|
|
397
382
|
this.checkQueueDrain();
|
|
398
383
|
// Not via dispatch: the text was headed at original dispatch, and a
|
|
399
384
|
// second pass could attribute these words to the operator.
|
|
400
|
-
invoked = true;
|
|
401
385
|
const submitted = mode === "steer" && session.state === "streaming"
|
|
402
386
|
? session.steer(text) : session.prompt(text);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
this.
|
|
406
|
-
|
|
387
|
+
this.submitting.set(sessionId, (this.submitting.get(sessionId) ?? 0) + 1);
|
|
388
|
+
void submitted.catch(failed).finally(() => {
|
|
389
|
+
const left = this.submitting.get(sessionId) - 1;
|
|
390
|
+
if (left)
|
|
391
|
+
this.submitting.set(sessionId, left);
|
|
392
|
+
else
|
|
393
|
+
this.submitting.delete(sessionId);
|
|
394
|
+
this.resumePromotion(sessionId);
|
|
395
|
+
});
|
|
407
396
|
return text;
|
|
408
397
|
}
|
|
409
398
|
catch (err) {
|
|
@@ -413,7 +402,7 @@ export class Router {
|
|
|
413
402
|
}, mode === "auto" ? this.conversationOf(sessionId) : undefined);
|
|
414
403
|
}
|
|
415
404
|
catch (err) {
|
|
416
|
-
if (!
|
|
405
|
+
if (!cleared && !(err instanceof QueueOperationError && (err.reason === "busy" || err.reason === "empty"))) {
|
|
417
406
|
this.reportTo(sessionId, `Could not promote queued messages: ${String(err)}`);
|
|
418
407
|
}
|
|
419
408
|
throw err;
|
|
@@ -431,10 +420,7 @@ export class Router {
|
|
|
431
420
|
resumePromotion(sessionId) {
|
|
432
421
|
// queue_update precedes the backend's enqueue. Never clear it reentrantly.
|
|
433
422
|
queueMicrotask(() => {
|
|
434
|
-
if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
|
|
435
|
-
return;
|
|
436
|
-
// Retained failures require a human decision, not an automatic resend.
|
|
437
|
-
if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
|
|
423
|
+
if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId) || this.submitting.has(sessionId))
|
|
438
424
|
return;
|
|
439
425
|
this.promotionRequested.delete(sessionId);
|
|
440
426
|
const attached = this.bySession.get(sessionId);
|
|
@@ -476,12 +462,14 @@ export class Router {
|
|
|
476
462
|
return this.draining;
|
|
477
463
|
}
|
|
478
464
|
/** Told to the chat directly (§5): an adapter's dispatch catch only logs. */
|
|
479
|
-
|
|
480
|
-
const message = "Pier is restarting — this message was not taken; send it again in a moment.";
|
|
465
|
+
refuse(key, err) {
|
|
481
466
|
this.channels.get(key.channelId)
|
|
482
|
-
?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
|
|
483
|
-
.catch((
|
|
484
|
-
throw
|
|
467
|
+
?.notify(key.conversationId, { text: err.message, origin: { kind: "error" } })
|
|
468
|
+
.catch((e) => log.error(`could not report the refusal to ${key.channelId}`, e));
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
refuseDraining(key) {
|
|
472
|
+
this.refuse(key, new Refused("Pier is restarting — this message was not taken; send it again in a moment."));
|
|
485
473
|
}
|
|
486
474
|
/** Attached sessions still mid-turn, and conversations whose answer is still
|
|
487
475
|
* going out (`sending`) — what the drain waits on, and what its deadline
|
|
@@ -549,7 +537,7 @@ export class Router {
|
|
|
549
537
|
* that is waiting. Either way the waiting side is not left with nothing. */
|
|
550
538
|
unopened(key, err) {
|
|
551
539
|
log.error(`could not open a session for ${keyOf(key)}`, err);
|
|
552
|
-
const message =
|
|
540
|
+
const message = cut(`could not open a session: ${String(err)}`, 600);
|
|
553
541
|
if (key.channelId === "web" || key.channelId === "task") {
|
|
554
542
|
this.reportTo(key.conversationId, message);
|
|
555
543
|
return;
|
|
@@ -581,13 +569,18 @@ export class Router {
|
|
|
581
569
|
const session = await this.ensure(msg.key);
|
|
582
570
|
if (this.draining)
|
|
583
571
|
this.refuseDraining(msg.key);
|
|
584
|
-
|
|
585
|
-
|
|
572
|
+
const skilled = skillText(msg.text, session.skills());
|
|
573
|
+
if (skilled instanceof SkillAmbiguous)
|
|
574
|
+
this.refuse(msg.key, skilled);
|
|
575
|
+
const { action, text } = decide({ ...msg, text: skilled }, session.state);
|
|
586
576
|
// A chat is named so the agent can hand it to a script (skills/pier-slack);
|
|
587
577
|
// an alias names nothing a shell could reach.
|
|
588
578
|
const where = isAlias(msg.key) ? undefined : keyOf(msg.key);
|
|
589
579
|
const opaque = this.channels.get(msg.key.channelId)?.opaqueIds;
|
|
590
|
-
const
|
|
580
|
+
const header = this.senders.next(session.id, msg.sender, Date.now(), where, opaque, text);
|
|
581
|
+
// Pi expands `/skill:<name>` only at the very start, so a header rides in its args.
|
|
582
|
+
const skill = header ? /^\/skill:\S+/.exec(text)?.[0] : undefined;
|
|
583
|
+
const prompt = skill ? `${skill} ${withPrefix(header, text.slice(skill.length).trimStart())}` : withPrefix(header, text);
|
|
591
584
|
log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
|
|
592
585
|
// A rejected call surfaces on the event stream, never as a throw across the seam.
|
|
593
586
|
session[action](prompt).catch((err) => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `pier search`: the `/search` route's host and the verb's argv, on the search
|
|
2
|
+
// seam core declares (`AgentFactory.search`); root keeps only the dispatch, as
|
|
3
|
+
// for `pier task` and `pier web`. Argv shape is checked here, the host validates
|
|
4
|
+
// the params, and its text reaches the shell verbatim (08 §Failure lines).
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { readableTitle } from "./identity.js";
|
|
7
|
+
const USAGE = "usage: pier search <q...> [--limit N] [--json]";
|
|
8
|
+
// The match marks are the palette's to paint; a line is one line.
|
|
9
|
+
const flat = (text) => text.replaceAll("\u0001", "").replaceAll("\u0002", "").replace(/\s+/g, " ").trim();
|
|
10
|
+
// A title may be a whole first message; the snippet is what the line is for.
|
|
11
|
+
const TITLE_CHARS = 30;
|
|
12
|
+
const clip = (text) => {
|
|
13
|
+
const chars = [...text];
|
|
14
|
+
return chars.length > TITLE_CHARS ? `${chars.slice(0, TITLE_CHARS - 1).join("").trimEnd()}…` : text;
|
|
15
|
+
};
|
|
16
|
+
// sv-SE spells local time as YYYY-MM-DD HH:MM.
|
|
17
|
+
const when = (at) => new Date(at).toLocaleString("sv-SE", { dateStyle: "short", timeStyle: "short" });
|
|
18
|
+
const processIo = {
|
|
19
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
20
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
21
|
+
};
|
|
22
|
+
/** `/search`'s params to hits, each titled with its session's name as it is
|
|
23
|
+
* now; a session gone from disk is named by its id. */
|
|
24
|
+
export const searchSessions = (factory) => async (params) => {
|
|
25
|
+
const { q, limit = 20 } = (params ?? {});
|
|
26
|
+
const query = typeof q === "string" ? q.trim() : "";
|
|
27
|
+
if (!query)
|
|
28
|
+
throw new Error("q must be non-empty words to search for");
|
|
29
|
+
if (!Number.isInteger(limit))
|
|
30
|
+
throw new Error("limit must be an integer");
|
|
31
|
+
const hits = await factory.search(query, Math.min(50, Math.max(1, limit)));
|
|
32
|
+
return {
|
|
33
|
+
hits: await Promise.all(hits.map(async (hit) => ({ ...hit, title: readableTitle((await factory.find(hit.sessionId))?.title) ?? hit.sessionId }))),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/** One line per hit, or the answer's object with `--json`. */
|
|
37
|
+
export async function runSearchCli(argv, post, io = processIo) {
|
|
38
|
+
let v = {};
|
|
39
|
+
let words = [];
|
|
40
|
+
try {
|
|
41
|
+
({ values: v, positionals: words } = parseArgs({ args: argv, allowPositionals: true, strict: true, options: { limit: { type: "string" }, json: { type: "boolean" } } }));
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
io.stderr(`search: ${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
if (!words.length) {
|
|
48
|
+
io.stderr(`search: say what to search for\n${USAGE}`);
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
const { status, body } = await post({ q: words.join(" "), ...(v.limit === undefined ? {} : { limit: Number(v.limit) }) });
|
|
52
|
+
if (status !== 200 || !body.result) {
|
|
53
|
+
io.stderr(`search: ${body.error ?? `socket answered ${String(status)}`}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
const { hits } = body.result;
|
|
57
|
+
if (v.json)
|
|
58
|
+
io.stdout(JSON.stringify(body.result));
|
|
59
|
+
else if (!hits.length)
|
|
60
|
+
io.stdout("no hits");
|
|
61
|
+
else
|
|
62
|
+
for (const hit of hits)
|
|
63
|
+
io.stdout(`${hit.sessionId} · ${clip(flat(hit.title))} · ${hit.role} · ${when(hit.at)}: ${flat(hit.snippet)}`);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
package/dist/core/types.js
CHANGED
|
@@ -1,87 +1,48 @@
|
|
|
1
|
-
// Normative seam types
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Normative seam types of the conversation — Channel, AgentSession, its events,
|
|
2
|
+
// the chain — THIS FILE is the system contract (docs/architecture.md documents
|
|
3
|
+
// the rules around it). Changing a seam is a design decision, not a refactor;
|
|
4
|
+
// keep it implementable over RPC (no Pi types may appear here).
|
|
5
|
+
/** The chat commands, each with the one line the composer's completion shows:
|
|
6
|
+
* a message to the continuous conversation that is exactly `/<word>` or `%<word>` is a
|
|
7
|
+
* command, never a message (core/chain.ts). Browser-safe: the composer
|
|
8
|
+
* lists this table. */
|
|
9
|
+
export const CHAT_COMMANDS = {
|
|
10
|
+
status: "what is open — in flight, or waiting on you",
|
|
11
|
+
new: "start a new session now",
|
|
12
|
+
stop: "stop the reply in progress",
|
|
13
|
+
skills: "the skills this session can run, by name",
|
|
14
|
+
};
|
|
15
|
+
export const isChatCommand = (v) => typeof v === "string" && Object.hasOwn(CHAT_COMMANDS, v);
|
|
16
|
+
/** How long the continuous conversation's head may go without a user message
|
|
17
|
+
* before the next one starts a new session: the "long" prompt-cache TTL
|
|
18
|
+
* interactive sessions request, past which the cache is cold anyway. */
|
|
19
|
+
export const CHAIN_IDLE_MS = 60 * 60_000;
|
|
20
|
+
/** The run ledger's recent window: what `pier task runs` lists and the open items join against. */
|
|
21
|
+
export const LEDGER_WINDOW_MS = 24 * 60 * 60_000;
|
|
22
|
+
/** How large the continuous conversation's head's context may grow before the
|
|
23
|
+
* next user message starts a new session: past it, a turn costs more than the
|
|
24
|
+
* rotation's one cache write pays back. */
|
|
25
|
+
export const CHAIN_FULL_TOKENS = 60_000;
|
|
26
|
+
/** Declared here, not in tasks/, because `BackgroundRun`, the `task-status` event core carries, names it.
|
|
27
|
+
* In order: the Console lists them so, and a worker count reads so. */
|
|
28
|
+
export const TASK_RUN_STATES = ["queued", "running", "succeeded", "failed", "cancelled", "interrupted", "skipped"];
|
|
29
|
+
/** The `state` of a run an open item names that the ledger no longer holds; its `name` is the id. */
|
|
30
|
+
export const NOT_IN_LEDGER = "not in the ledger";
|
|
4
31
|
/** How much of a tool result any surface ever shows. A transcript replay
|
|
5
32
|
* carries no more than that: a session's tool output is most of its history
|
|
6
33
|
* payload, and the bytes past this point were downloaded to be sliced off. */
|
|
7
34
|
export const MAX_STEP_OUTPUT = 8_000;
|
|
8
35
|
/** How much of a message becomes a title, wherever one is derived: the listing
|
|
9
|
-
* reading a transcript (agent/listing.ts),
|
|
10
|
-
* the fill at first prompt
|
|
36
|
+
* reading a transcript (agent/listing.ts), the title model's answer
|
|
37
|
+
* (agent/pi.ts), the fill at first prompt (web/). */
|
|
11
38
|
export const SESSION_TITLE_MAX = 80;
|
|
39
|
+
/** The one spelling a model is compared and printed by. */
|
|
40
|
+
export const modelKey = (m) => `${m.provider}/${m.id}`;
|
|
12
41
|
/** Every level Pi accepts, in order. The union is derived so the two cannot
|
|
13
42
|
* drift, and boundary validators use isThinkingLevel instead of their own copy. */
|
|
14
43
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
15
44
|
export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.reason = reason;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
// Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
|
|
24
|
-
// non-Pi backend is bound to them by this seam.
|
|
25
|
-
const PROVIDER_APIS = [
|
|
26
|
-
"openai-completions",
|
|
27
|
-
"openai-responses",
|
|
28
|
-
"anthropic-messages",
|
|
29
|
-
"google-generative-ai",
|
|
30
|
-
];
|
|
31
|
-
export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
|
|
32
|
-
/** How far a model's reasoning goes. Every reasoning model offers up to
|
|
33
|
-
* "high"; the two levels above it exist only for a model whose catalog entry
|
|
34
|
-
* says so, which is the one thing a Console-defined model could not say. */
|
|
35
|
-
export const MODEL_EFFORTS = ["high", "xhigh", "max"];
|
|
36
|
-
/** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
|
|
37
|
-
* write and web/ pre-checks them at its HTTP boundary, and neither may import
|
|
38
|
-
* the other. Throws the message the surface shows. */
|
|
39
|
-
export function validateProviderSetup(input) {
|
|
40
|
-
if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
|
|
41
|
-
throw new Error("invalid provider id");
|
|
42
|
-
}
|
|
43
|
-
if (input.endpoint) {
|
|
44
|
-
if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
|
|
45
|
-
throw new Error("invalid endpoint");
|
|
46
|
-
}
|
|
47
|
-
validateEndpoint(input.endpoint);
|
|
48
|
-
}
|
|
49
|
-
if (input.kind === "builtin")
|
|
50
|
-
return;
|
|
51
|
-
if (!input.endpoint)
|
|
52
|
-
throw new Error("custom provider endpoint required");
|
|
53
|
-
if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
|
|
54
|
-
throw new Error("invalid provider name");
|
|
55
|
-
}
|
|
56
|
-
if (!isProviderApi(input.api))
|
|
57
|
-
throw new Error("unsupported provider API");
|
|
58
|
-
if (!input.models.length || input.models.length > 100)
|
|
59
|
-
throw new Error("1-100 models required");
|
|
60
|
-
const ids = input.models.map((model) => model.id);
|
|
61
|
-
if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
|
|
62
|
-
throw new Error("model ids must be non-empty, trimmed and unique");
|
|
63
|
-
}
|
|
64
|
-
// An effort ceiling on a model that does not reason would be written into
|
|
65
|
-
// the catalog and never offered — a setting that lies about itself.
|
|
66
|
-
if (input.models.some((model) => model.effort !== undefined && !model.reasoning)) {
|
|
67
|
-
throw new Error("effort requires reasoning");
|
|
68
|
-
}
|
|
69
|
-
if (input.models.some((model) => model.effort !== undefined && !MODEL_EFFORTS.includes(model.effort))) {
|
|
70
|
-
throw new Error("unsupported model effort");
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
export function validateEndpoint(endpoint) {
|
|
74
|
-
let url;
|
|
75
|
-
try {
|
|
76
|
-
url = new URL(endpoint);
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
throw new Error("endpoint must be an http(s) URL");
|
|
80
|
-
}
|
|
81
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
82
|
-
throw new Error("endpoint must be an http(s) URL");
|
|
83
|
-
}
|
|
84
|
-
if (url.username || url.password || url.search || url.hash) {
|
|
85
|
-
throw new Error("endpoint must not contain credentials, query or fragment");
|
|
86
|
-
}
|
|
87
|
-
}
|
|
45
|
+
/** The work classes a dispatcher names instead of a model; a tier's menu
|
|
46
|
+
* entries are tried in order. Here, not in settings.ts, so the browser shares it. */
|
|
47
|
+
export const MODEL_TIERS = ["hardest", "balanced", "cheap"];
|
|
48
|
+
export const isModelTier = (v) => typeof v === "string" && MODEL_TIERS.includes(v);
|