@timqi/pier 0.0.1 → 0.0.3
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 +87 -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-tool.js +28 -3
- 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 +200 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +180 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +192 -18
- package/dist/settings.js +77 -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 +92 -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 +146 -0
- package/dist/web/auth.js +89 -26
- package/dist/web/explorer.js +147 -0
- package/dist/web/files.js +28 -12
- package/dist/web/instance.js +165 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +141 -0
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -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 +50 -32
- package/dist/web/server.js +110 -120
- package/docs/deploy.md +142 -64
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +20 -3
- 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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Inbound user files: bytes land on disk exactly once.
|
|
2
|
+
//
|
|
3
|
+
// A photo pasted on the web, dropped in Telegram or uploaded to Slack used to
|
|
4
|
+
// travel as base64 through the seam into the transcript, where it was re-sent
|
|
5
|
+
// with every provider request until compaction. Now the adapter (or the web
|
|
6
|
+
// upload route) saves the bytes under `$PIER_HOME/inbox/<channel>/` and the
|
|
7
|
+
// prompt carries only a marker line (core/inbound-file.ts owns that grammar),
|
|
8
|
+
// so the agent reads a file only when it decides the file is worth looking at.
|
|
9
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
import { pierPath } from "../paths.js";
|
|
13
|
+
import { safeName } from "./inbound-file.js";
|
|
14
|
+
/** Where every inbound file lives; web/files.ts allowlists this root. */
|
|
15
|
+
export const INBOX_DIR = pierPath("inbox");
|
|
16
|
+
/**
|
|
17
|
+
* Write one inbound file and return its absolute path. The timestamp-random
|
|
18
|
+
* prefix keeps concurrent saves collision-free (`wx` turns the impossible
|
|
19
|
+
* collision into an error instead of an overwrite) and makes `ls` read as a
|
|
20
|
+
* timeline. Owner-only modes: uploads are private conversation content on a
|
|
21
|
+
* possibly shared machine. Nothing is ever deleted here — pruning the inbox
|
|
22
|
+
* is the operator's call (docs/deploy.md).
|
|
23
|
+
*/
|
|
24
|
+
export async function saveInbound(channelId, name, mimeType, bytes) {
|
|
25
|
+
// The channel id is ours ("web" | "telegram" | "slack"), not user input,
|
|
26
|
+
// but basename() keeps a future id honest.
|
|
27
|
+
const dir = join(INBOX_DIR, basename(channelId));
|
|
28
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
const path = join(dir, `${String(Date.now())}-${randomBytes(3).toString("hex")}-${safeName(name, mimeType)}`);
|
|
30
|
+
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
31
|
+
return path;
|
|
32
|
+
}
|
package/dist/core/queue.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// The whole queue policy. Fixed by docs/architecture.md — do not add options.
|
|
2
2
|
export function decide(msg, state) {
|
|
3
|
+
// An explicit mode takes the text verbatim: IM sends steer for every
|
|
4
|
+
// message, so a leading "!" there is content, not a control prefix —
|
|
5
|
+
// consuming it silently rewrote what the person typed.
|
|
6
|
+
if (msg.mode === "steer" || msg.mode === "followUp") {
|
|
7
|
+
return { action: state === "idle" ? "prompt" : msg.mode, text: msg.text };
|
|
8
|
+
}
|
|
9
|
+
// On auto the "!" is a control prefix and always consumed — whether the
|
|
10
|
+
// turn happened to end first must not decide if it was content. Idle just
|
|
11
|
+
// means there is nothing to steer, so it degenerates to a prompt.
|
|
3
12
|
const steerPrefixed = msg.text.startsWith("!");
|
|
4
13
|
const text = steerPrefixed ? msg.text.slice(1).trimStart() : msg.text;
|
|
5
14
|
if (state === "idle")
|
|
6
15
|
return { action: "prompt", text };
|
|
7
|
-
if (msg.mode === "steer" || msg.mode === "followUp") {
|
|
8
|
-
return { action: msg.mode, text };
|
|
9
|
-
}
|
|
10
16
|
return { action: steerPrefixed ? "steer" : "followUp", text };
|
|
11
17
|
}
|
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,17 @@ 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 by a graceful restart (src/drain.ts). Usually never unset, because
|
|
33
|
+
* the process exits when the drain ends — `endDrain` exists for the one
|
|
34
|
+
* caller that drains *speculatively* and may not get to exit. */
|
|
35
|
+
draining = false;
|
|
22
36
|
constructor(hub,
|
|
23
37
|
/** Create or resume the session owning a conversation (wired in main.ts). */
|
|
24
38
|
resolve) {
|
|
@@ -55,6 +69,67 @@ export class Router {
|
|
|
55
69
|
});
|
|
56
70
|
});
|
|
57
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Report something that happened *to* a session rather than in it: a task
|
|
74
|
+
* result that could not be delivered, say. Its conversation is told when one
|
|
75
|
+
* is attached — an agent that was promised an answer and a human watching the
|
|
76
|
+
* same thread learn it is not coming from the same place they were waiting.
|
|
77
|
+
* Otherwise the hub carries it for the web timeline.
|
|
78
|
+
*/
|
|
79
|
+
reportTo(sessionId, message) {
|
|
80
|
+
const key = this.conversationOf(sessionId);
|
|
81
|
+
if (key)
|
|
82
|
+
this.report(sessionId, key, message);
|
|
83
|
+
else
|
|
84
|
+
this.hub.emit(sessionId, { type: "error", message });
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Let go of every session that has been idle too long, so a process serving
|
|
88
|
+
* IM threads and task runs for weeks does not hold one live Pi runtime per
|
|
89
|
+
* conversation it ever saw. Only the in-memory attachment goes: the durable
|
|
90
|
+
* conversation → session mapping stays, so the next message resumes the very
|
|
91
|
+
* same transcript (channels/conversations.ts).
|
|
92
|
+
*
|
|
93
|
+
* Skipped for anything that would notice: a streaming turn, and a session
|
|
94
|
+
* someone is still watching over SSE.
|
|
95
|
+
*
|
|
96
|
+
* `includeWatched` is the one caller that may take a watched session too:
|
|
97
|
+
* configuration a session reads only when it opens has just changed, and the
|
|
98
|
+
* session most likely to need it is the one open in the tab that changed it.
|
|
99
|
+
* A turn in flight is still never touched — the exemption that stands is the
|
|
100
|
+
* one about interrupting work, not the one about being looked at.
|
|
101
|
+
*/
|
|
102
|
+
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now(), { includeWatched = false } = {}) {
|
|
103
|
+
let evicted = 0;
|
|
104
|
+
for (const [id, attached] of [...this.bySession]) {
|
|
105
|
+
if (attached.session.state === "streaming")
|
|
106
|
+
continue;
|
|
107
|
+
if (!includeWatched && this.hub.hasSubscribers(id))
|
|
108
|
+
continue;
|
|
109
|
+
if (now - attached.activeAt < ttlMs)
|
|
110
|
+
continue;
|
|
111
|
+
this.bySession.delete(id);
|
|
112
|
+
this.forgetKeys(attached.session);
|
|
113
|
+
attached.unsubscribe();
|
|
114
|
+
this.senders.forget(id);
|
|
115
|
+
this.hub.dropReplay(id);
|
|
116
|
+
evicted += 1;
|
|
117
|
+
log.info(`evicted idle session ${id} (${keyOf(attached.key)})`);
|
|
118
|
+
// Best-effort: a runtime that will not shut down must not keep the
|
|
119
|
+
// sweeper from releasing the rest.
|
|
120
|
+
await attached.session.dispose().catch((err) => log.error(`disposing session ${id} failed`, err));
|
|
121
|
+
}
|
|
122
|
+
return evicted;
|
|
123
|
+
}
|
|
124
|
+
/** Run evictIdle on a timer. Returns the stop function (main.ts owns it). */
|
|
125
|
+
startIdleEviction() {
|
|
126
|
+
// Unref'd: a sweep pending is never a reason for the process to stay up.
|
|
127
|
+
const timer = setInterval(() => {
|
|
128
|
+
void this.evictIdle().catch((err) => log.error("idle sweep failed", err));
|
|
129
|
+
}, SWEEP_MS);
|
|
130
|
+
timer.unref();
|
|
131
|
+
return () => clearInterval(timer);
|
|
132
|
+
}
|
|
58
133
|
stateOf(sessionId) {
|
|
59
134
|
return this.bySession.get(sessionId)?.session.state;
|
|
60
135
|
}
|
|
@@ -74,21 +149,43 @@ export class Router {
|
|
|
74
149
|
conversationOf(sessionId) {
|
|
75
150
|
return this.bySession.get(sessionId)?.key;
|
|
76
151
|
}
|
|
152
|
+
/** Every key that points at this session object. One session can be reached
|
|
153
|
+
* under more than one — `web:<id>` and `task:<id>` name the same session —
|
|
154
|
+
* and a key left behind hands out a session that is no longer live. */
|
|
155
|
+
forgetKeys(session) {
|
|
156
|
+
for (const [key, held] of this.byKey)
|
|
157
|
+
if (held === session)
|
|
158
|
+
this.byKey.delete(key);
|
|
159
|
+
}
|
|
77
160
|
/** Attach an existing session to a conversation and wire its events. */
|
|
78
161
|
attach(key, session) {
|
|
79
|
-
this.byKey.set(keyOf(key), session);
|
|
80
162
|
const existing = this.bySession.get(session.id);
|
|
81
|
-
if (existing?.session === session)
|
|
163
|
+
if (existing?.session === session) {
|
|
164
|
+
this.byKey.set(keyOf(key), session);
|
|
165
|
+
existing.activeAt = Date.now();
|
|
82
166
|
return;
|
|
83
|
-
|
|
167
|
+
}
|
|
168
|
+
if (existing) {
|
|
169
|
+
// Two live objects on one transcript: both would write it and both would
|
|
170
|
+
// answer the chat. Single-flight `ensure` closes the race that makes
|
|
171
|
+
// this, so reaching here is a bug worth seeing — the replaced one is
|
|
172
|
+
// silenced and unreachable, rather than left answering under aliases
|
|
173
|
+
// nobody knows are stale.
|
|
174
|
+
log.warn(`session ${session.id} replaced while attached to ${keyOf(existing.key)}`);
|
|
175
|
+
existing.unsubscribe();
|
|
176
|
+
this.forgetKeys(existing.session);
|
|
177
|
+
}
|
|
178
|
+
this.byKey.set(keyOf(key), session);
|
|
84
179
|
log.info(`attached ${keyOf(key)} → session ${session.id}`);
|
|
85
|
-
session.subscribe((payload) => {
|
|
180
|
+
const unsubscribe = session.subscribe((payload) => {
|
|
86
181
|
this.hub.emit(session.id, payload);
|
|
87
182
|
// Run state is workspace-visible: every client's session list shows it.
|
|
88
183
|
if (payload.type === "state") {
|
|
89
184
|
const attached = this.bySession.get(session.id);
|
|
185
|
+
// Every turn passes through here, so this is also where a session
|
|
186
|
+
// proves to the sweeper that it is still in use.
|
|
90
187
|
if (attached)
|
|
91
|
-
attached.stateSince = Date.now();
|
|
188
|
+
attached.stateSince = attached.activeAt = Date.now();
|
|
92
189
|
this.hub.emitWorkspace({
|
|
93
190
|
type: "session-state",
|
|
94
191
|
sessionId: session.id,
|
|
@@ -132,10 +229,50 @@ export class Router {
|
|
|
132
229
|
}
|
|
133
230
|
}
|
|
134
231
|
});
|
|
232
|
+
this.bySession.set(session.id, {
|
|
233
|
+
session,
|
|
234
|
+
key,
|
|
235
|
+
stateSince: Date.now(),
|
|
236
|
+
activeAt: Date.now(),
|
|
237
|
+
unsubscribe,
|
|
238
|
+
});
|
|
135
239
|
}
|
|
136
240
|
async abort(sessionId) {
|
|
137
241
|
await this.bySession.get(sessionId)?.session.abort();
|
|
138
242
|
}
|
|
243
|
+
/** Refuse new work from every surface; in-flight turns keep running. */
|
|
244
|
+
beginDrain() {
|
|
245
|
+
this.draining = true;
|
|
246
|
+
}
|
|
247
|
+
/** Take work again. The auto-updater closes the gate *before* handing over,
|
|
248
|
+
* so a turn cannot slip in behind the idle check — and when the handover
|
|
249
|
+
* never happens, a Pier left refusing every message forever would be a far
|
|
250
|
+
* worse outcome than the race it was avoiding. */
|
|
251
|
+
endDrain() {
|
|
252
|
+
this.draining = false;
|
|
253
|
+
}
|
|
254
|
+
/** For surfaces that mutate state before dispatching (the web's edit and
|
|
255
|
+
* queue-deliver routes): ask first, so a refused dispatch cannot cost a
|
|
256
|
+
* rewound transcript or a cleared queue. */
|
|
257
|
+
isDraining() {
|
|
258
|
+
return this.draining;
|
|
259
|
+
}
|
|
260
|
+
/** The drain gate, throwing. Told to the chat directly (5b): an adapter's
|
|
261
|
+
* dispatch catch only logs, and the web caller gets the throw. */
|
|
262
|
+
refuseDraining(key) {
|
|
263
|
+
const message = "Pier is restarting — this message was not taken; send it again in a moment.";
|
|
264
|
+
this.channels.get(key.channelId)
|
|
265
|
+
?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
|
|
266
|
+
.catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
|
|
267
|
+
throw new Error(message);
|
|
268
|
+
}
|
|
269
|
+
/** Attached sessions still mid-turn — what the drain waits on, and what its
|
|
270
|
+
* deadline snapshots into the ledger. */
|
|
271
|
+
busy() {
|
|
272
|
+
return [...this.bySession.values()]
|
|
273
|
+
.filter((attached) => attached.session.state === "streaming")
|
|
274
|
+
.map((attached) => ({ session: attached.session, key: attached.key }));
|
|
275
|
+
}
|
|
139
276
|
/**
|
|
140
277
|
* The session already attached to a conversation, if any. Never creates one:
|
|
141
278
|
* a channel's stop or settings command must not be what opens a session.
|
|
@@ -157,31 +294,80 @@ export class Router {
|
|
|
157
294
|
this.byKey.set(keyOf(key), session);
|
|
158
295
|
}
|
|
159
296
|
if (!session) {
|
|
297
|
+
// Aliases share one lock: web:<id> and task:<id> must not each open one.
|
|
298
|
+
const lock = key.channelId === "web" || key.channelId === "task"
|
|
299
|
+
? `session:${key.conversationId}`
|
|
300
|
+
: keyOf(key);
|
|
301
|
+
const inflight = this.opening.get(lock);
|
|
302
|
+
// A second caller rides the first one's resolve — which attaches before
|
|
303
|
+
// this continuation runs, having awaited it first — and registers its own
|
|
304
|
+
// key against the session that came back.
|
|
305
|
+
if (inflight) {
|
|
306
|
+
session = await inflight;
|
|
307
|
+
this.byKey.set(keyOf(key), session);
|
|
308
|
+
return this.reached(session);
|
|
309
|
+
}
|
|
160
310
|
try {
|
|
161
|
-
|
|
311
|
+
// Inside the try: a resolver that throws synchronously is the same
|
|
312
|
+
// failure as one that rejects, and reports the same way.
|
|
313
|
+
const opening = this.resolve(key);
|
|
314
|
+
this.opening.set(lock, opening);
|
|
315
|
+
session = await opening;
|
|
162
316
|
}
|
|
163
317
|
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);
|
|
318
|
+
this.unopened(key, err);
|
|
167
319
|
throw err;
|
|
168
320
|
}
|
|
321
|
+
finally {
|
|
322
|
+
this.opening.delete(lock);
|
|
323
|
+
}
|
|
169
324
|
this.attach(key, session);
|
|
170
325
|
}
|
|
326
|
+
return this.reached(session);
|
|
327
|
+
}
|
|
328
|
+
/** A session that would not open has no event stream of its own to report on
|
|
329
|
+
* — unless its id is what we were asked for, which is what a web or task key
|
|
330
|
+
* is. Otherwise the chat that is waiting is told directly. Callers still get
|
|
331
|
+
* the rejection; this is only so the waiting side is not left with nothing. */
|
|
332
|
+
unopened(key, err) {
|
|
333
|
+
log.error(`could not open a session for ${keyOf(key)}`, err);
|
|
334
|
+
const message = truncate(`could not open a session: ${String(err)}`);
|
|
335
|
+
// A web or task key names the session that would not open, so its own
|
|
336
|
+
// stream is where the waiting surface is looking; an IM key names a chat.
|
|
337
|
+
if (key.channelId === "web" || key.channelId === "task") {
|
|
338
|
+
this.reportTo(key.conversationId, message);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
this.channels.get(key.channelId)
|
|
342
|
+
?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
|
|
343
|
+
.catch((e) => log.error(`could not report it to ${key.channelId}`, e));
|
|
344
|
+
}
|
|
345
|
+
/** Reached for, so not idle — every surface that uses a session comes
|
|
346
|
+
* through `ensure`, including the ones that only read it. */
|
|
347
|
+
reached(session) {
|
|
348
|
+
const attached = this.bySession.get(session.id);
|
|
349
|
+
if (attached)
|
|
350
|
+
attached.activeAt = Date.now();
|
|
171
351
|
return session;
|
|
172
352
|
}
|
|
173
353
|
async dispatch(msg) {
|
|
354
|
+
// Before ensure — a drain must not be what opens a session …
|
|
355
|
+
if (this.draining)
|
|
356
|
+
this.refuseDraining(msg.key);
|
|
174
357
|
const session = await this.ensure(msg.key);
|
|
358
|
+
// … and after — a dispatch that was inside a slow ensure when the gate
|
|
359
|
+
// closed must not start the turn the drain just declared finished with.
|
|
360
|
+
if (this.draining)
|
|
361
|
+
this.refuseDraining(msg.key);
|
|
175
362
|
const { action, text } = decide(msg, session.state);
|
|
176
363
|
// A group chat is many people talking into one session; without a speaker
|
|
177
364
|
// line the agent cannot tell them apart or mention anyone back. Emitted
|
|
178
365
|
// only when the speaker or the clock says something new.
|
|
179
366
|
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)`);
|
|
367
|
+
log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
|
|
182
368
|
// Turn outcomes flow through the event stream; a rejected call surfaces
|
|
183
369
|
// there too, never as a thrown exception across the seam.
|
|
184
|
-
session[action](prompt
|
|
370
|
+
session[action](prompt).catch((err) => {
|
|
185
371
|
this.report(session.id, msg.key, String(err));
|
|
186
372
|
});
|
|
187
373
|
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) {
|