@timqi/pier 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/dist/core/reply.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* into the factory). Both halves of the feature live in this file: the syntax
|
|
14
14
|
* the agent is told to emit, and the parser that reads it back.
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
const REPLY_SURFACE_PROMPT = `## Pier chat surface
|
|
17
17
|
|
|
18
18
|
Your replies render in a chat UI (web and IM). Three optional markdown
|
|
19
19
|
conventions:
|
|
@@ -25,15 +25,19 @@ conventions:
|
|
|
25
25
|
- **Attachments** — link a file you produced by absolute \`file://\` URL:
|
|
26
26
|
\`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
|
|
27
27
|
other files as a download card; only files inside the session's working
|
|
28
|
-
directory are readable.
|
|
28
|
+
directory or Pier's inbox are readable. The same convention runs inbound: a
|
|
29
|
+
user message ending in \`[name](file:///…)\` lines is carrying files the
|
|
30
|
+
sender attached, already saved to disk — read one only when it matters to
|
|
31
|
+
the task; every read puts its content in your context for good.
|
|
29
32
|
- **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
|
|
30
33
|
remains no message is sent. In a group chat you are handed every message,
|
|
31
34
|
including humans talking to each other: stay silent rather than acknowledge
|
|
32
35
|
what was not addressed to you.
|
|
33
36
|
|
|
34
37
|
A message may start with \`[name<id> time]\` — the sender, added by Pier, not
|
|
35
|
-
typed by them. It appears only
|
|
36
|
-
|
|
38
|
+
typed by them. It appears only on a change — new speaker, a ~10-minute gap, a
|
|
39
|
+
new day — so the last one still applies; a gap alone shows as time only, like
|
|
40
|
+
\`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
|
|
37
41
|
`;
|
|
38
42
|
/**
|
|
39
43
|
* The contract above plus the two facts about *this* deployment that an agent
|
|
@@ -124,6 +128,17 @@ export function cjkFriendly(markdown) {
|
|
|
124
128
|
});
|
|
125
129
|
return out.replace(/\uE010(\d+)\uE011/g, (_m, i) => stash[Number(i)] ?? "");
|
|
126
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The one wording for a turn that said nothing, wherever it is shown — the
|
|
133
|
+
* chat surfaces and the task result path. The reason arrives pre-escaped
|
|
134
|
+
* because each surface escapes for its own markup; the label itself contains
|
|
135
|
+
* nothing any of them escape.
|
|
136
|
+
*/
|
|
137
|
+
export const quietLabel = (silence) => silence ? `stayed silent — ${silence}` : "no reply";
|
|
138
|
+
/** One policy for "did this turn actually reply": options count as a reply —
|
|
139
|
+
* the buttons are the answer, so a turn that is only its options is not
|
|
140
|
+
* "nothing". Every surface decides through here. */
|
|
141
|
+
export const isSilentReply = (reply) => !reply.text.trim() && reply.suggestions.length === 0;
|
|
127
142
|
/** How a reasoning level is spelled wherever a human reads it. */
|
|
128
143
|
export const thinkingLabel = (level) => level === "xhigh" ? "Extra high" : level[0].toUpperCase() + level.slice(1);
|
|
129
144
|
/** 1200 → "1.2K", 12_000 → "12K" — absolute token counts read badly inline. */
|
|
@@ -159,7 +174,6 @@ export const formatTurnMeta = (meta) => `${formatDuration(meta.durationMs)} · $
|
|
|
159
174
|
const BLOCK = /(?:^|\n)[ \t]*-{3,}[ \t]*\r?\n((?:[ \t]*\[[^\]\r\n]+\][ \t]*(?:[||][ \t]*)?)+)\s*$/;
|
|
160
175
|
const TOKEN = /\[([^\]\r\n]+)\]/g;
|
|
161
176
|
const MAX_SUGGESTIONS = 5;
|
|
162
|
-
/** Split an assistant turn's markdown into renderable text + next-step labels. */
|
|
163
177
|
/**
|
|
164
178
|
* A deliberate non-answer. In a group thread the agent is handed every message,
|
|
165
179
|
* and most of them are two humans talking; a bot that replies to each one is
|
|
@@ -183,6 +197,7 @@ export function silentReason(markdown) {
|
|
|
183
197
|
.filter(Boolean);
|
|
184
198
|
return reasons.length ? reasons.join(" · ") : undefined;
|
|
185
199
|
}
|
|
200
|
+
/** Split an assistant turn's markdown into renderable text + next-step labels. */
|
|
186
201
|
export function splitReply(rawMarkdown, meta) {
|
|
187
202
|
// Every surface that renders this goes through here, and every CommonMark
|
|
188
203
|
// parser has some version of the CJK emphasis hole — so the repair belongs
|
package/dist/core/router.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
// Conversation → session routing plus event wiring. In-memory
|
|
2
|
-
//
|
|
1
|
+
// Conversation → session routing plus event wiring. In-memory on purpose:
|
|
2
|
+
// the durable chat → session map lives in channels/conversations.ts.
|
|
3
3
|
import { logger } from "../log.js";
|
|
4
4
|
import { EventHub } from "./hub.js";
|
|
5
5
|
import { SenderPrefix, withPrefix } from "./identity.js";
|
|
6
6
|
import { decide } from "./queue.js";
|
|
7
7
|
import { splitReply } from "./reply.js";
|
|
8
8
|
const log = logger("core");
|
|
9
|
+
/** How long a session may sit idle in memory before it is let go. Generous on
|
|
10
|
+
* purpose: eviction is a memory measure, and re-opening one costs a Pi
|
|
11
|
+
* resume plus the transcript being read back. */
|
|
12
|
+
const IDLE_TTL_MS = 30 * 60_000;
|
|
13
|
+
/** Sweep interval. Nothing here is urgent, so it is coarse. */
|
|
14
|
+
const SWEEP_MS = 5 * 60_000;
|
|
9
15
|
/** An error goes into a chat window, so it is trimmed to something readable. */
|
|
10
16
|
const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
|
|
11
17
|
function keyOf(key) {
|
|
@@ -16,9 +22,16 @@ export class Router {
|
|
|
16
22
|
resolve;
|
|
17
23
|
byKey = new Map();
|
|
18
24
|
bySession = new Map();
|
|
25
|
+
/** Resolves in flight, so two surfaces asking at once share one session
|
|
26
|
+
* object instead of opening a second Pi runtime on the same transcript.
|
|
27
|
+
* Web and task keys collapse to the session id they both name. */
|
|
28
|
+
opening = new Map();
|
|
19
29
|
channels = new Map();
|
|
20
30
|
/** Who each session last heard from, so a header costs tokens only on news. */
|
|
21
31
|
senders = new SenderPrefix();
|
|
32
|
+
/** Set once by a graceful restart (src/drain.ts); never unset — the process
|
|
33
|
+
* exits when the drain ends. */
|
|
34
|
+
draining = false;
|
|
22
35
|
constructor(hub,
|
|
23
36
|
/** Create or resume the session owning a conversation (wired in main.ts). */
|
|
24
37
|
resolve) {
|
|
@@ -55,6 +68,61 @@ export class Router {
|
|
|
55
68
|
});
|
|
56
69
|
});
|
|
57
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Report something that happened *to* a session rather than in it: a task
|
|
73
|
+
* result that could not be delivered, say. Its conversation is told when one
|
|
74
|
+
* is attached — an agent that was promised an answer and a human watching the
|
|
75
|
+
* same thread learn it is not coming from the same place they were waiting.
|
|
76
|
+
* Otherwise the hub carries it for the web timeline.
|
|
77
|
+
*/
|
|
78
|
+
reportTo(sessionId, message) {
|
|
79
|
+
const key = this.conversationOf(sessionId);
|
|
80
|
+
if (key)
|
|
81
|
+
this.report(sessionId, key, message);
|
|
82
|
+
else
|
|
83
|
+
this.hub.emit(sessionId, { type: "error", message });
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Let go of every session that has been idle too long, so a process serving
|
|
87
|
+
* IM threads and task runs for weeks does not hold one live Pi runtime per
|
|
88
|
+
* conversation it ever saw. Only the in-memory attachment goes: the durable
|
|
89
|
+
* conversation → session mapping stays, so the next message resumes the very
|
|
90
|
+
* same transcript (channels/conversations.ts).
|
|
91
|
+
*
|
|
92
|
+
* Skipped for anything that would notice: a streaming turn, and a session
|
|
93
|
+
* someone is still watching over SSE.
|
|
94
|
+
*/
|
|
95
|
+
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now()) {
|
|
96
|
+
let evicted = 0;
|
|
97
|
+
for (const [id, attached] of [...this.bySession]) {
|
|
98
|
+
if (attached.session.state === "streaming")
|
|
99
|
+
continue;
|
|
100
|
+
if (this.hub.hasSubscribers(id))
|
|
101
|
+
continue;
|
|
102
|
+
if (now - attached.activeAt < ttlMs)
|
|
103
|
+
continue;
|
|
104
|
+
this.bySession.delete(id);
|
|
105
|
+
this.forgetKeys(attached.session);
|
|
106
|
+
attached.unsubscribe();
|
|
107
|
+
this.senders.forget(id);
|
|
108
|
+
this.hub.dropReplay(id);
|
|
109
|
+
evicted += 1;
|
|
110
|
+
log.info(`evicted idle session ${id} (${keyOf(attached.key)})`);
|
|
111
|
+
// Best-effort: a runtime that will not shut down must not keep the
|
|
112
|
+
// sweeper from releasing the rest.
|
|
113
|
+
await attached.session.dispose().catch((err) => log.error(`disposing session ${id} failed`, err));
|
|
114
|
+
}
|
|
115
|
+
return evicted;
|
|
116
|
+
}
|
|
117
|
+
/** Run evictIdle on a timer. Returns the stop function (main.ts owns it). */
|
|
118
|
+
startIdleEviction() {
|
|
119
|
+
// Unref'd: a sweep pending is never a reason for the process to stay up.
|
|
120
|
+
const timer = setInterval(() => {
|
|
121
|
+
void this.evictIdle().catch((err) => log.error("idle sweep failed", err));
|
|
122
|
+
}, SWEEP_MS);
|
|
123
|
+
timer.unref();
|
|
124
|
+
return () => clearInterval(timer);
|
|
125
|
+
}
|
|
58
126
|
stateOf(sessionId) {
|
|
59
127
|
return this.bySession.get(sessionId)?.session.state;
|
|
60
128
|
}
|
|
@@ -74,21 +142,43 @@ export class Router {
|
|
|
74
142
|
conversationOf(sessionId) {
|
|
75
143
|
return this.bySession.get(sessionId)?.key;
|
|
76
144
|
}
|
|
145
|
+
/** Every key that points at this session object. One session can be reached
|
|
146
|
+
* under more than one — `web:<id>` and `task:<id>` name the same session —
|
|
147
|
+
* and a key left behind hands out a session that is no longer live. */
|
|
148
|
+
forgetKeys(session) {
|
|
149
|
+
for (const [key, held] of this.byKey)
|
|
150
|
+
if (held === session)
|
|
151
|
+
this.byKey.delete(key);
|
|
152
|
+
}
|
|
77
153
|
/** Attach an existing session to a conversation and wire its events. */
|
|
78
154
|
attach(key, session) {
|
|
79
|
-
this.byKey.set(keyOf(key), session);
|
|
80
155
|
const existing = this.bySession.get(session.id);
|
|
81
|
-
if (existing?.session === session)
|
|
156
|
+
if (existing?.session === session) {
|
|
157
|
+
this.byKey.set(keyOf(key), session);
|
|
158
|
+
existing.activeAt = Date.now();
|
|
82
159
|
return;
|
|
83
|
-
|
|
160
|
+
}
|
|
161
|
+
if (existing) {
|
|
162
|
+
// Two live objects on one transcript: both would write it and both would
|
|
163
|
+
// answer the chat. Single-flight `ensure` closes the race that makes
|
|
164
|
+
// this, so reaching here is a bug worth seeing — the replaced one is
|
|
165
|
+
// silenced and unreachable, rather than left answering under aliases
|
|
166
|
+
// nobody knows are stale.
|
|
167
|
+
log.warn(`session ${session.id} replaced while attached to ${keyOf(existing.key)}`);
|
|
168
|
+
existing.unsubscribe();
|
|
169
|
+
this.forgetKeys(existing.session);
|
|
170
|
+
}
|
|
171
|
+
this.byKey.set(keyOf(key), session);
|
|
84
172
|
log.info(`attached ${keyOf(key)} → session ${session.id}`);
|
|
85
|
-
session.subscribe((payload) => {
|
|
173
|
+
const unsubscribe = session.subscribe((payload) => {
|
|
86
174
|
this.hub.emit(session.id, payload);
|
|
87
175
|
// Run state is workspace-visible: every client's session list shows it.
|
|
88
176
|
if (payload.type === "state") {
|
|
89
177
|
const attached = this.bySession.get(session.id);
|
|
178
|
+
// Every turn passes through here, so this is also where a session
|
|
179
|
+
// proves to the sweeper that it is still in use.
|
|
90
180
|
if (attached)
|
|
91
|
-
attached.stateSince = Date.now();
|
|
181
|
+
attached.stateSince = attached.activeAt = Date.now();
|
|
92
182
|
this.hub.emitWorkspace({
|
|
93
183
|
type: "session-state",
|
|
94
184
|
sessionId: session.id,
|
|
@@ -132,10 +222,43 @@ export class Router {
|
|
|
132
222
|
}
|
|
133
223
|
}
|
|
134
224
|
});
|
|
225
|
+
this.bySession.set(session.id, {
|
|
226
|
+
session,
|
|
227
|
+
key,
|
|
228
|
+
stateSince: Date.now(),
|
|
229
|
+
activeAt: Date.now(),
|
|
230
|
+
unsubscribe,
|
|
231
|
+
});
|
|
135
232
|
}
|
|
136
233
|
async abort(sessionId) {
|
|
137
234
|
await this.bySession.get(sessionId)?.session.abort();
|
|
138
235
|
}
|
|
236
|
+
/** Refuse new work from every surface; in-flight turns keep running. */
|
|
237
|
+
beginDrain() {
|
|
238
|
+
this.draining = true;
|
|
239
|
+
}
|
|
240
|
+
/** For surfaces that mutate state before dispatching (the web's edit and
|
|
241
|
+
* queue-deliver routes): ask first, so a refused dispatch cannot cost a
|
|
242
|
+
* rewound transcript or a cleared queue. */
|
|
243
|
+
isDraining() {
|
|
244
|
+
return this.draining;
|
|
245
|
+
}
|
|
246
|
+
/** The drain gate, throwing. Told to the chat directly (5b): an adapter's
|
|
247
|
+
* dispatch catch only logs, and the web caller gets the throw. */
|
|
248
|
+
refuseDraining(key) {
|
|
249
|
+
const message = "Pier is restarting — this message was not taken; send it again in a moment.";
|
|
250
|
+
this.channels.get(key.channelId)
|
|
251
|
+
?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
|
|
252
|
+
.catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
|
|
253
|
+
throw new Error(message);
|
|
254
|
+
}
|
|
255
|
+
/** Attached sessions still mid-turn — what the drain waits on, and what its
|
|
256
|
+
* deadline snapshots into the ledger. */
|
|
257
|
+
busy() {
|
|
258
|
+
return [...this.bySession.values()]
|
|
259
|
+
.filter((attached) => attached.session.state === "streaming")
|
|
260
|
+
.map((attached) => ({ session: attached.session, key: attached.key }));
|
|
261
|
+
}
|
|
139
262
|
/**
|
|
140
263
|
* The session already attached to a conversation, if any. Never creates one:
|
|
141
264
|
* a channel's stop or settings command must not be what opens a session.
|
|
@@ -157,31 +280,80 @@ export class Router {
|
|
|
157
280
|
this.byKey.set(keyOf(key), session);
|
|
158
281
|
}
|
|
159
282
|
if (!session) {
|
|
283
|
+
// Aliases share one lock: web:<id> and task:<id> must not each open one.
|
|
284
|
+
const lock = key.channelId === "web" || key.channelId === "task"
|
|
285
|
+
? `session:${key.conversationId}`
|
|
286
|
+
: keyOf(key);
|
|
287
|
+
const inflight = this.opening.get(lock);
|
|
288
|
+
// A second caller rides the first one's resolve — which attaches before
|
|
289
|
+
// this continuation runs, having awaited it first — and registers its own
|
|
290
|
+
// key against the session that came back.
|
|
291
|
+
if (inflight) {
|
|
292
|
+
session = await inflight;
|
|
293
|
+
this.byKey.set(keyOf(key), session);
|
|
294
|
+
return this.reached(session);
|
|
295
|
+
}
|
|
160
296
|
try {
|
|
161
|
-
|
|
297
|
+
// Inside the try: a resolver that throws synchronously is the same
|
|
298
|
+
// failure as one that rejects, and reports the same way.
|
|
299
|
+
const opening = this.resolve(key);
|
|
300
|
+
this.opening.set(lock, opening);
|
|
301
|
+
session = await opening;
|
|
162
302
|
}
|
|
163
303
|
catch (err) {
|
|
164
|
-
|
|
165
|
-
// to emit under yet, and every caller only sees a rejected promise.
|
|
166
|
-
log.error(`could not open a session for ${keyOf(key)}`, err);
|
|
304
|
+
this.unopened(key, err);
|
|
167
305
|
throw err;
|
|
168
306
|
}
|
|
307
|
+
finally {
|
|
308
|
+
this.opening.delete(lock);
|
|
309
|
+
}
|
|
169
310
|
this.attach(key, session);
|
|
170
311
|
}
|
|
312
|
+
return this.reached(session);
|
|
313
|
+
}
|
|
314
|
+
/** A session that would not open has no event stream of its own to report on
|
|
315
|
+
* — unless its id is what we were asked for, which is what a web or task key
|
|
316
|
+
* is. Otherwise the chat that is waiting is told directly. Callers still get
|
|
317
|
+
* the rejection; this is only so the waiting side is not left with nothing. */
|
|
318
|
+
unopened(key, err) {
|
|
319
|
+
log.error(`could not open a session for ${keyOf(key)}`, err);
|
|
320
|
+
const message = truncate(`could not open a session: ${String(err)}`);
|
|
321
|
+
// A web or task key names the session that would not open, so its own
|
|
322
|
+
// stream is where the waiting surface is looking; an IM key names a chat.
|
|
323
|
+
if (key.channelId === "web" || key.channelId === "task") {
|
|
324
|
+
this.reportTo(key.conversationId, message);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.channels.get(key.channelId)
|
|
328
|
+
?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
|
|
329
|
+
.catch((e) => log.error(`could not report it to ${key.channelId}`, e));
|
|
330
|
+
}
|
|
331
|
+
/** Reached for, so not idle — every surface that uses a session comes
|
|
332
|
+
* through `ensure`, including the ones that only read it. */
|
|
333
|
+
reached(session) {
|
|
334
|
+
const attached = this.bySession.get(session.id);
|
|
335
|
+
if (attached)
|
|
336
|
+
attached.activeAt = Date.now();
|
|
171
337
|
return session;
|
|
172
338
|
}
|
|
173
339
|
async dispatch(msg) {
|
|
340
|
+
// Before ensure — a drain must not be what opens a session …
|
|
341
|
+
if (this.draining)
|
|
342
|
+
this.refuseDraining(msg.key);
|
|
174
343
|
const session = await this.ensure(msg.key);
|
|
344
|
+
// … and after — a dispatch that was inside a slow ensure when the gate
|
|
345
|
+
// closed must not start the turn the drain just declared finished with.
|
|
346
|
+
if (this.draining)
|
|
347
|
+
this.refuseDraining(msg.key);
|
|
175
348
|
const { action, text } = decide(msg, session.state);
|
|
176
349
|
// A group chat is many people talking into one session; without a speaker
|
|
177
350
|
// line the agent cannot tell them apart or mention anyone back. Emitted
|
|
178
351
|
// only when the speaker or the clock says something new.
|
|
179
352
|
const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
|
|
180
|
-
log.debug(`${action} ${keyOf(msg.key)} → session ${session.id}`
|
|
181
|
-
` (${String(prompt.length)} chars, ${String(msg.images?.length ?? 0)} images)`);
|
|
353
|
+
log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
|
|
182
354
|
// Turn outcomes flow through the event stream; a rejected call surfaces
|
|
183
355
|
// there too, never as a thrown exception across the seam.
|
|
184
|
-
session[action](prompt
|
|
356
|
+
session[action](prompt).catch((err) => {
|
|
185
357
|
this.report(session.id, msg.key, String(err));
|
|
186
358
|
});
|
|
187
359
|
return { sessionId: session.id };
|
package/dist/core/types.js
CHANGED
|
@@ -5,3 +5,56 @@
|
|
|
5
5
|
* drift, and boundary validators use isThinkingLevel instead of their own copy. */
|
|
6
6
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
7
7
|
export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
|
|
8
|
+
// Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
|
|
9
|
+
// non-Pi backend is bound to them by this seam.
|
|
10
|
+
export const PROVIDER_APIS = [
|
|
11
|
+
"openai-completions",
|
|
12
|
+
"openai-responses",
|
|
13
|
+
"anthropic-messages",
|
|
14
|
+
"google-generative-ai",
|
|
15
|
+
];
|
|
16
|
+
export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
|
|
17
|
+
/** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
|
|
18
|
+
* write and web/ pre-checks them at its HTTP boundary, and neither may import
|
|
19
|
+
* the other. Throws the message the surface shows. */
|
|
20
|
+
export function validateProviderSetup(input) {
|
|
21
|
+
if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
|
|
22
|
+
throw new Error("invalid provider id");
|
|
23
|
+
}
|
|
24
|
+
if (input.endpoint) {
|
|
25
|
+
if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
|
|
26
|
+
throw new Error("invalid endpoint");
|
|
27
|
+
}
|
|
28
|
+
validateEndpoint(input.endpoint);
|
|
29
|
+
}
|
|
30
|
+
if (input.kind === "builtin")
|
|
31
|
+
return;
|
|
32
|
+
if (!input.endpoint)
|
|
33
|
+
throw new Error("custom provider endpoint required");
|
|
34
|
+
if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
|
|
35
|
+
throw new Error("invalid provider name");
|
|
36
|
+
}
|
|
37
|
+
if (!isProviderApi(input.api))
|
|
38
|
+
throw new Error("unsupported provider API");
|
|
39
|
+
if (!input.models.length || input.models.length > 100)
|
|
40
|
+
throw new Error("1-100 models required");
|
|
41
|
+
const ids = input.models.map((model) => model.id);
|
|
42
|
+
if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
|
|
43
|
+
throw new Error("model ids must be non-empty, trimmed and unique");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function validateEndpoint(endpoint) {
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(endpoint);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error("endpoint must be an http(s) URL");
|
|
53
|
+
}
|
|
54
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
55
|
+
throw new Error("endpoint must be an http(s) URL");
|
|
56
|
+
}
|
|
57
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
58
|
+
throw new Error("endpoint must not contain credentials, query or fragment");
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -126,6 +126,18 @@ const MIGRATIONS = [
|
|
|
126
126
|
key TEXT PRIMARY KEY,
|
|
127
127
|
value TEXT NOT NULL
|
|
128
128
|
);
|
|
129
|
+
`,
|
|
130
|
+
// 3 — what a restart's drain deadline cut off, told to the chat at next boot.
|
|
131
|
+
`
|
|
132
|
+
-- Written only when a graceful restart aborts a still-running turn; the next
|
|
133
|
+
-- boot delivers each row and clears it once delivered. Owned by drain.ts.
|
|
134
|
+
CREATE TABLE restart_ledger (
|
|
135
|
+
id INTEGER PRIMARY KEY,
|
|
136
|
+
channel_id TEXT NOT NULL,
|
|
137
|
+
conversation_id TEXT NOT NULL,
|
|
138
|
+
note TEXT NOT NULL,
|
|
139
|
+
created_at INTEGER NOT NULL
|
|
140
|
+
);
|
|
129
141
|
`,
|
|
130
142
|
];
|
|
131
143
|
let shared;
|
|
@@ -134,6 +146,17 @@ let shared;
|
|
|
134
146
|
* defaults to it; a test passes `openDb(":memory:")` instead.
|
|
135
147
|
*/
|
|
136
148
|
export const pierDb = () => (shared ??= openDb(PIER_DB));
|
|
149
|
+
/** A release-level restore point, taken while the service is stopped even when
|
|
150
|
+
* the release has no schema migration. The previous complete copy stays put if
|
|
151
|
+
* writing its replacement fails. */
|
|
152
|
+
export function backupDb(path = PIER_DB) {
|
|
153
|
+
if (!existsSync(path))
|
|
154
|
+
return undefined;
|
|
155
|
+
const bak = `${path}.release.bak`;
|
|
156
|
+
copyDatabase(path, bak);
|
|
157
|
+
log.info(`pre-update backup: ${bak}`);
|
|
158
|
+
return bak;
|
|
159
|
+
}
|
|
137
160
|
/** Open a database, bring it to the current schema, and lock down its files.
|
|
138
161
|
* `migrations` is injectable only so tests can exercise an upgrade — there is
|
|
139
162
|
* exactly one real list. */
|
|
@@ -141,10 +164,11 @@ export function openDb(path, migrations = MIGRATIONS) {
|
|
|
141
164
|
if (path !== ":memory:")
|
|
142
165
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
143
166
|
const db = new DatabaseSync(path);
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
167
|
+
// Timeout first: two processes booting together contend on the WAL switch
|
|
168
|
+
// itself. Outside the transaction below: journal_mode is a property of the
|
|
169
|
+
// file, and SQLite refuses to change it inside one.
|
|
147
170
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
171
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
148
172
|
migrate(db, path, migrations);
|
|
149
173
|
if (path !== ":memory:")
|
|
150
174
|
restrict(path);
|
|
@@ -175,8 +199,6 @@ function migrate(db, path, migrations) {
|
|
|
175
199
|
}
|
|
176
200
|
if (at === target)
|
|
177
201
|
return;
|
|
178
|
-
if (at > 0 && path !== ":memory:")
|
|
179
|
-
snapshot(db, path, at);
|
|
180
202
|
// One transaction for the statements *and* the version number: a crash
|
|
181
203
|
// between them would leave a database whose version describes a schema it
|
|
182
204
|
// does not have, which is worse than a crash.
|
|
@@ -188,9 +210,24 @@ function migrate(db, path, migrations) {
|
|
|
188
210
|
const { user_version: locked } = db.prepare("PRAGMA user_version").get();
|
|
189
211
|
if (locked >= target) {
|
|
190
212
|
db.exec("ROLLBACK");
|
|
213
|
+
if (locked > target) {
|
|
214
|
+
throw new Error(`${path} advanced to schema ${locked} while this Pier was waiting; it speaks ${target}`);
|
|
215
|
+
}
|
|
191
216
|
log.info(`schema already at ${locked}, migrated by another process`);
|
|
192
217
|
return;
|
|
193
218
|
}
|
|
219
|
+
// Keep the write lock while a second, read-only connection copies the last
|
|
220
|
+
// committed state. That connection may VACUUM while this one holds a RESERVED
|
|
221
|
+
// lock; other Pier starts wait here instead of racing on the shared .tmp.
|
|
222
|
+
if (locked > 0 && path !== ":memory:") {
|
|
223
|
+
try {
|
|
224
|
+
snapshot(path, locked);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
db.exec("ROLLBACK");
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
194
231
|
let step = locked;
|
|
195
232
|
try {
|
|
196
233
|
for (; step < target; step++)
|
|
@@ -221,14 +258,23 @@ function migrate(db, path, migrations) {
|
|
|
221
258
|
* old snapshot nor a complete new one. A rename is atomic: the `.bak` name only
|
|
222
259
|
* ever refers to a finished copy.
|
|
223
260
|
*/
|
|
224
|
-
function snapshot(
|
|
261
|
+
function snapshot(path, at) {
|
|
225
262
|
const bak = `${path}.v${at}.bak`;
|
|
263
|
+
copyDatabase(path, bak);
|
|
264
|
+
log.info(`pre-migration backup: ${bak}`);
|
|
265
|
+
}
|
|
266
|
+
function copyDatabase(path, bak) {
|
|
226
267
|
const tmp = `${bak}.tmp`;
|
|
227
268
|
rmSync(tmp, { force: true }); // a previous crash may have left one
|
|
228
|
-
|
|
269
|
+
const source = new DatabaseSync(path, { readOnly: true });
|
|
270
|
+
try {
|
|
271
|
+
source.exec(`VACUUM INTO '${tmp.replaceAll("'", "''")}'`);
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
source.close();
|
|
275
|
+
}
|
|
229
276
|
chmodSync(tmp, 0o600); // it holds everything the 0600 database holds
|
|
230
277
|
renameSync(tmp, bak);
|
|
231
|
-
log.info(`pre-migration backup: ${bak}`);
|
|
232
278
|
}
|
|
233
279
|
/** Snapshots beside the database, newest schema first. */
|
|
234
280
|
function backups(path) {
|
package/dist/drain.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// A graceful restart: refuse new work, let running turns finish, and write
|
|
2
|
+
// down what the deadline had to cut off so the next boot can tell the chats.
|
|
3
|
+
//
|
|
4
|
+
// The trigger is SIGUSR2 (main.ts); systemd's `Restart=always` is the "start
|
|
5
|
+
// again" half. SIGTERM stays the fast path systemd expects — this file is only
|
|
6
|
+
// the slow one. Nothing here is persisted for its own sake: everything durable
|
|
7
|
+
// (transcripts, the chat → session map, task runs) already survives a restart,
|
|
8
|
+
// so the ledger below holds only the one thing that would otherwise vanish
|
|
9
|
+
// silently — turns and queued messages the deadline aborted (§5b).
|
|
10
|
+
import { logger } from "./log.js";
|
|
11
|
+
const log = logger("drain");
|
|
12
|
+
/** How long running turns may take before they are aborted. Generous: a turn
|
|
13
|
+
* can be a subagent fan-out, and an abort still persists the partial work. */
|
|
14
|
+
export const DRAIN_DEADLINE_MS = 5 * 60_000;
|
|
15
|
+
const POLL_MS = 1_000;
|
|
16
|
+
/** Shared cleanup window after the deadline. All sessions use the same clock,
|
|
17
|
+
* so N hung seams still cost at most this long rather than N times as long. */
|
|
18
|
+
const CLEANUP_BOUND_MS = 10_000;
|
|
19
|
+
/** What the dying process owes the chats, held for the next one to deliver. */
|
|
20
|
+
export class RestartLedger {
|
|
21
|
+
db;
|
|
22
|
+
constructor(db) {
|
|
23
|
+
this.db = db;
|
|
24
|
+
}
|
|
25
|
+
record(entry) {
|
|
26
|
+
this.db.prepare("INSERT INTO restart_ledger (channel_id, conversation_id, note, created_at) VALUES (?, ?, ?, ?)").run(entry.channelId, entry.conversationId, entry.note, Date.now());
|
|
27
|
+
}
|
|
28
|
+
list() {
|
|
29
|
+
const rows = this.db.prepare("SELECT id, channel_id, conversation_id, note FROM restart_ledger ORDER BY id").all();
|
|
30
|
+
return rows.map((row) => ({
|
|
31
|
+
id: row.id,
|
|
32
|
+
channelId: row.channel_id,
|
|
33
|
+
conversationId: row.conversation_id,
|
|
34
|
+
note: row.note,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
remove(id) {
|
|
38
|
+
this.db.prepare("DELETE FROM restart_ledger WHERE id = ?").run(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve when the process may exit: every turn settled and every task run
|
|
43
|
+
* terminal, or the deadline reached and the stragglers aborted into the
|
|
44
|
+
* ledger. The caller (main.ts) owns what happens next — the ordinary shutdown,
|
|
45
|
+
* minus aborting task runs: the boot-time interrupted marking is the recovery
|
|
46
|
+
* path (tasks/service.ts start()), not a teardown race against dying channels.
|
|
47
|
+
*/
|
|
48
|
+
export async function drainForRestart(deps, deadlineMs = DRAIN_DEADLINE_MS, pollMs = POLL_MS, cleanupBoundMs = CLEANUP_BOUND_MS) {
|
|
49
|
+
const { router, tasks, ledger } = deps;
|
|
50
|
+
router.beginDrain();
|
|
51
|
+
tasks.pause();
|
|
52
|
+
const deadline = Date.now() + deadlineMs;
|
|
53
|
+
let lastReport = "";
|
|
54
|
+
for (;;) {
|
|
55
|
+
// Sleep first: a prompt accepted just before the gate closed may not have
|
|
56
|
+
// flipped its session to streaming yet, and exiting on that blink would
|
|
57
|
+
// cut off the very turn the drain exists to protect.
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
59
|
+
const busy = router.busy();
|
|
60
|
+
const runs = tasks.activeRunCount();
|
|
61
|
+
if (busy.length === 0 && runs === 0) {
|
|
62
|
+
log.info("drained — nothing running");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (Date.now() >= deadline) {
|
|
66
|
+
log.warn(`drain deadline after ${String(Math.round(deadlineMs / 1000))}s — aborting ${String(busy.length)} turn(s); ` +
|
|
67
|
+
`${String(runs)} task run(s) will be marked interrupted at boot`);
|
|
68
|
+
const cleanupDeadline = Date.now() + cleanupBoundMs;
|
|
69
|
+
await Promise.all(busy.map(({ session, key }) => abortToLedger(session, key, ledger, cleanupDeadline)));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const report = `draining: ${String(busy.length)} turn(s), ${String(runs)} active task run(s)`;
|
|
73
|
+
if (report !== lastReport)
|
|
74
|
+
log.info((lastReport = report));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** A seam call the deadline cannot wait on forever: a hang or a rejection is
|
|
78
|
+
* logged and answered with the fallback, and cleanup moves on. */
|
|
79
|
+
async function bounded(work, ms, what, fallback) {
|
|
80
|
+
let timer;
|
|
81
|
+
const timeout = new Promise((resolve) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
log.error(`${what} did not answer within ${String(ms)}ms`);
|
|
84
|
+
resolve(fallback);
|
|
85
|
+
}, ms);
|
|
86
|
+
timer.unref();
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([
|
|
90
|
+
work.catch((err) => {
|
|
91
|
+
log.error(`${what} failed`, err);
|
|
92
|
+
return fallback;
|
|
93
|
+
}),
|
|
94
|
+
timeout,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (timer)
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Write the chat's entry, then abort the turn. The ledger comes first so a
|
|
103
|
+
* hung abort cannot cost the note; the abort persists the partial transcript;
|
|
104
|
+
* the pending queue would just vanish, so its texts ride along. */
|
|
105
|
+
async function abortToLedger(session, key, ledger, cleanupDeadline) {
|
|
106
|
+
const remaining = () => Math.max(0, cleanupDeadline - Date.now());
|
|
107
|
+
const queued = await bounded(session.pendingQueue(), remaining(), `queue snapshot of session ${session.id}`, { steering: [], followUp: [] });
|
|
108
|
+
const pending = [...queued.steering, ...queued.followUp];
|
|
109
|
+
// A web or task key has no chat to write to: the transcript shows the
|
|
110
|
+
// aborted turn, and a task run's interruption is reported by its callback
|
|
111
|
+
// recovery. Only a dropped queue would be invisible there, so it is at
|
|
112
|
+
// least logged.
|
|
113
|
+
if (key.channelId === "web" || key.channelId === "task") {
|
|
114
|
+
if (pending.length) {
|
|
115
|
+
log.warn(`session ${session.id}: ${String(pending.length)} queued message(s) dropped by the restart`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const note = [
|
|
120
|
+
"Pier restarted before this turn finished — the last message may be unanswered.",
|
|
121
|
+
...(pending.length ? ["Queued and not delivered:", ...pending.map((text) => `> ${text}`)] : []),
|
|
122
|
+
].join("\n");
|
|
123
|
+
ledger.record({ channelId: key.channelId, conversationId: key.conversationId, note });
|
|
124
|
+
}
|
|
125
|
+
await bounded(session.abort(), remaining(), `abort of session ${session.id}`, undefined);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Deliver what a previous process wrote on its way out. Runs at boot once the
|
|
129
|
+
* adapters are up, and again on a Console unlock. Each entry is removed only
|
|
130
|
+
* after confirmed delivery. A missing adapter or a thrown notification keeps
|
|
131
|
+
* the debt for the next start: a duplicate apology is preferable to silence.
|
|
132
|
+
*/
|
|
133
|
+
export async function deliverLedger(ledger, notify) {
|
|
134
|
+
for (const entry of ledger.list()) {
|
|
135
|
+
const target = `${entry.channelId}:${entry.conversationId}`;
|
|
136
|
+
const delivered = await notify(entry).catch((err) => {
|
|
137
|
+
log.error(`restart note to ${target} failed`, err);
|
|
138
|
+
return null;
|
|
139
|
+
});
|
|
140
|
+
if (delivered === true)
|
|
141
|
+
ledger.remove(entry.id);
|
|
142
|
+
else if (delivered === false)
|
|
143
|
+
log.warn(`restart note waiting — ${target} is not running: ${entry.note}`);
|
|
144
|
+
}
|
|
145
|
+
}
|