@timqi/pier 0.1.0 → 0.1.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 +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
- package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
- package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
- package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js +85 -0
- package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
- package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
- package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
- package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/drain.js
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
// A graceful restart: refuse new work, let running turns finish, and write
|
|
2
|
-
// down what the deadline
|
|
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).
|
|
2
|
+
// down what the deadline cut off so the next boot can tell the chats (§5).
|
|
3
|
+
// Everything else durable already survives a restart.
|
|
10
4
|
import { logger } from "./log.js";
|
|
11
5
|
const log = logger("drain");
|
|
12
|
-
/**
|
|
13
|
-
* can be a subagent fan-out, and an abort still persists the partial work. */
|
|
6
|
+
/** Generous: a turn can be a subagent fan-out. */
|
|
14
7
|
const DRAIN_DEADLINE_MS = 5 * 60_000;
|
|
15
8
|
const POLL_MS = 1_000;
|
|
16
|
-
/** Shared
|
|
17
|
-
* so N hung seams still cost at most this long rather than N times as long. */
|
|
9
|
+
/** Shared across sessions, so N hung seams cost this long, not N times it. */
|
|
18
10
|
const CLEANUP_BOUND_MS = 10_000;
|
|
19
11
|
/** What the dying process owes the chats, held for the next one to deliver. */
|
|
20
12
|
export class RestartLedger {
|
|
@@ -38,13 +30,9 @@ export class RestartLedger {
|
|
|
38
30
|
this.db.prepare("DELETE FROM restart_ledger WHERE id = ?").run(id);
|
|
39
31
|
}
|
|
40
32
|
}
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
*/
|
|
33
|
+
/** Resolves when the process may exit: everything settled, or the deadline
|
|
34
|
+
* reached and the stragglers aborted into the ledger. Task runs are left to
|
|
35
|
+
* the boot-time interrupted marking (tasks/service.ts). */
|
|
48
36
|
export async function drainForRestart(deps, deadlineMs = DRAIN_DEADLINE_MS, pollMs = POLL_MS, cleanupBoundMs = CLEANUP_BOUND_MS) {
|
|
49
37
|
const { router, tasks, ledger } = deps;
|
|
50
38
|
router.beginDrain();
|
|
@@ -53,29 +41,38 @@ export async function drainForRestart(deps, deadlineMs = DRAIN_DEADLINE_MS, poll
|
|
|
53
41
|
let lastReport = "";
|
|
54
42
|
for (;;) {
|
|
55
43
|
// Sleep first: a prompt accepted just before the gate closed may not have
|
|
56
|
-
// flipped its session to streaming yet
|
|
57
|
-
// cut off the very turn the drain exists to protect.
|
|
44
|
+
// flipped its session to streaming yet.
|
|
58
45
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
59
46
|
const busy = router.busy();
|
|
60
47
|
const runs = tasks.activeRunCount();
|
|
61
48
|
if (busy.length === 0 && runs === 0) {
|
|
62
49
|
log.info("drained — nothing running");
|
|
50
|
+
await queuesToLedger(router, ledger, new Set(), Date.now() + cleanupBoundMs);
|
|
63
51
|
return;
|
|
64
52
|
}
|
|
53
|
+
const turns = busy.filter((b) => !b.sending);
|
|
54
|
+
const sends = busy.filter((b) => b.sending);
|
|
65
55
|
if (Date.now() >= deadline) {
|
|
66
|
-
log.warn(`drain deadline after ${String(Math.round(deadlineMs / 1000))}s — aborting ${String(
|
|
67
|
-
`${String(runs)} task run(s) will be marked interrupted at boot`);
|
|
56
|
+
log.warn(`drain deadline after ${String(Math.round(deadlineMs / 1000))}s — aborting ${String(turns.length)} turn(s), ` +
|
|
57
|
+
`${String(sends.length)} reply(ies) still sending; ${String(runs)} task run(s) will be marked interrupted at boot`);
|
|
58
|
+
// A send cannot be aborted, only owned up to: the exit will cut it off.
|
|
59
|
+
for (const { key } of sends) {
|
|
60
|
+
ledger.record({
|
|
61
|
+
channelId: key.channelId, conversationId: key.conversationId,
|
|
62
|
+
note: "Pier restarted while sending the last answer — it may have arrived incomplete; the session transcript has all of it.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
68
65
|
const cleanupDeadline = Date.now() + cleanupBoundMs;
|
|
69
|
-
await Promise.all(
|
|
66
|
+
await Promise.all(turns.map(({ session, key }) => abortToLedger(session, key, ledger, cleanupDeadline)));
|
|
67
|
+
await queuesToLedger(router, ledger, new Set(turns.map(({ session }) => session.id)), cleanupDeadline);
|
|
70
68
|
return;
|
|
71
69
|
}
|
|
72
|
-
const report = `draining: ${String(
|
|
70
|
+
const report = `draining: ${String(turns.length)} turn(s), ${String(sends.length)} reply(ies) sending, ${String(runs)} active task run(s)`;
|
|
73
71
|
if (report !== lastReport)
|
|
74
72
|
log.info((lastReport = report));
|
|
75
73
|
}
|
|
76
74
|
}
|
|
77
|
-
/** A
|
|
78
|
-
* logged and answered with the fallback, and cleanup moves on. */
|
|
75
|
+
/** A hang or a rejection is logged and answered with the fallback. */
|
|
79
76
|
async function bounded(work, ms, what, fallback) {
|
|
80
77
|
let timer;
|
|
81
78
|
const timeout = new Promise((resolve) => {
|
|
@@ -99,37 +96,47 @@ async function bounded(work, ms, what, fallback) {
|
|
|
99
96
|
clearTimeout(timer);
|
|
100
97
|
}
|
|
101
98
|
}
|
|
102
|
-
/**
|
|
103
|
-
*
|
|
104
|
-
* the pending queue would just vanish, so its texts ride along. */
|
|
99
|
+
/** Ledger first, so a hung abort cannot cost the note; the pending queue would
|
|
100
|
+
* just vanish, so its texts ride along. */
|
|
105
101
|
async function abortToLedger(session, key, ledger, cleanupDeadline) {
|
|
106
102
|
const remaining = () => Math.max(0, cleanupDeadline - Date.now());
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
103
|
+
recordRestartNote(ledger, session, key, await queueSnapshot(session, remaining()));
|
|
104
|
+
await bounded(session.abort(), remaining(), `abort of session ${session.id}`, undefined);
|
|
105
|
+
}
|
|
106
|
+
/** Pi's queue lives only in the runtime, so the exit ends it whether a turn was
|
|
107
|
+
* running or not: an attached session nobody was waiting on still owes its
|
|
108
|
+
* chat the texts it never got to (§5). `handled` are the aborted turns, whose
|
|
109
|
+
* note already carries their queue. */
|
|
110
|
+
async function queuesToLedger(router, ledger, handled, cleanupDeadline) {
|
|
111
|
+
await Promise.all(router.attachedSessions()
|
|
112
|
+
.filter(({ session }) => !handled.has(session.id))
|
|
113
|
+
.map(async ({ session, key }) => {
|
|
114
|
+
const pending = await queueSnapshot(session, Math.max(0, cleanupDeadline - Date.now()));
|
|
115
|
+
if (pending.length)
|
|
116
|
+
recordRestartNote(ledger, session, key, pending);
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
async function queueSnapshot(session, boundMs) {
|
|
120
|
+
const queued = await bounded(session.pendingQueue(), boundMs, `queue snapshot of session ${session.id}`, { steering: [], followUp: [] });
|
|
121
|
+
return [...queued.steering, ...queued.followUp];
|
|
122
|
+
}
|
|
123
|
+
function recordRestartNote(ledger, session, key, pending) {
|
|
124
|
+
// A web or task key has no chat: the transcript shows the aborted turn, and
|
|
125
|
+
// only a dropped queue would be invisible, so that is logged.
|
|
113
126
|
if (key.channelId === "web" || key.channelId === "task") {
|
|
114
127
|
if (pending.length) {
|
|
115
128
|
log.warn(`session ${session.id}: ${String(pending.length)} queued message(s) dropped by the restart`);
|
|
116
129
|
}
|
|
130
|
+
return;
|
|
117
131
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
ledger.record({ channelId: key.channelId, conversationId: key.conversationId, note });
|
|
124
|
-
}
|
|
125
|
-
await bounded(session.abort(), remaining(), `abort of session ${session.id}`, undefined);
|
|
132
|
+
const note = [
|
|
133
|
+
"Pier restarted before this turn finished — the last message may be unanswered.",
|
|
134
|
+
...(pending.length ? ["Queued and not delivered:", ...pending.map((text) => `> ${text}`)] : []),
|
|
135
|
+
].join("\n");
|
|
136
|
+
ledger.record({ channelId: key.channelId, conversationId: key.conversationId, note });
|
|
126
137
|
}
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
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
|
-
*/
|
|
138
|
+
/** Each entry is removed only after confirmed delivery: a duplicate apology is
|
|
139
|
+
* preferable to silence. */
|
|
133
140
|
export async function deliverLedger(ledger, notify) {
|
|
134
141
|
for (const entry of ledger.list()) {
|
|
135
142
|
const target = `${entry.channelId}:${entry.conversationId}`;
|
package/dist/extensions/index.js
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
// The extensions Pier ships with
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// this area is the second one allowed to import the Pi SDK. Nothing outside
|
|
5
|
-
// agent/ imports it: the Console sees names and summaries, which agent/ hands
|
|
6
|
-
// over as plain data through the ConfigStore seam.
|
|
7
|
-
//
|
|
8
|
-
// Bundled rather than dropped in <agentDir>/extensions because a copy on disk
|
|
9
|
-
// has an owner problem — an update either clobbers the user's edits or skips
|
|
10
|
-
// them forever. These ship inside the package, load as inline factories, and
|
|
11
|
-
// stand down when a copy on disk already registers the same tools.
|
|
1
|
+
// The extensions Pier ships with. Bundled rather than dropped in
|
|
2
|
+
// <agentDir>/extensions because a copy on disk has an owner problem: an update
|
|
3
|
+
// either clobbers the user's edits or skips them forever.
|
|
12
4
|
import web from "./web/index.js";
|
|
13
5
|
export const BUNDLED = [
|
|
14
6
|
{
|
|
@@ -22,14 +22,8 @@ const findCode = (value) => {
|
|
|
22
22
|
return undefined;
|
|
23
23
|
return typeof value.error_code === "string" ? value.error_code : findCode(value.content);
|
|
24
24
|
};
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
* throw: these arrive per invocation — the third search can fail while the
|
|
28
|
-
* first two are in the transcript and the briefing is written from them. This
|
|
29
|
-
* used to abort the whole call on any of them, which threw away a good answer
|
|
30
|
-
* over `max_uses_exceeded`, a code we provoke ourselves by budgeting the
|
|
31
|
-
* searches the prompt then asks for.
|
|
32
|
-
*/
|
|
25
|
+
/** A list, not a throw: the third search can fail (`max_uses_exceeded`, which
|
|
26
|
+
* our own budget provokes) while the briefing is written from the first two. */
|
|
33
27
|
function toolErrors(content) {
|
|
34
28
|
const codes = [];
|
|
35
29
|
for (const block of content) {
|
|
@@ -58,7 +52,7 @@ function hasContent(content) {
|
|
|
58
52
|
}
|
|
59
53
|
export async function callNativeTool(request, name, prompt, options, signal,
|
|
60
54
|
/** Progress for the surface the call came from: a hosted search is tens of
|
|
61
|
-
* seconds of nothing otherwise (§
|
|
55
|
+
* seconds of nothing otherwise (§5). */
|
|
62
56
|
note) {
|
|
63
57
|
const tool = {
|
|
64
58
|
type: NATIVE_TOOL_TYPES[name],
|
|
@@ -38,11 +38,8 @@ const digest = (value) => createHash("sha256").update(value).digest("hex").slice
|
|
|
38
38
|
export async function saveArtifact(url, text, retrievedAt) {
|
|
39
39
|
await mkdir(ARTIFACT_DIR, { recursive: true, mode: 0o700 });
|
|
40
40
|
const host = url.hostname.replace(/[^a-zA-Z0-9.-]+/g, "-").slice(0, 80) || "page";
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// transcript's `artifactPath` still points at — the one promise this file
|
|
44
|
-
// makes. Content decides, so a refetch that changed writes a new file and one
|
|
45
|
-
// that did not costs nothing.
|
|
41
|
+
// Content in the key: keying on the URL alone would overwrite the copy an
|
|
42
|
+
// older transcript's `artifactPath` still points at.
|
|
46
43
|
const path = join(ARTIFACT_DIR, `${host}-${digest(url.toString())}-${digest(text)}.md`);
|
|
47
44
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
48
45
|
const header = [
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
// What a provider's answer becomes on the way to the model:
|
|
2
|
-
//
|
|
3
|
-
// One shape for both backends, so a tool renders its answer once instead of
|
|
4
|
-
// per wire format — anthropic.ts and openai.ts parse into these, and nothing
|
|
5
|
-
// past this file knows which one replied.
|
|
1
|
+
// What a provider's answer becomes on the way to the model: one shape for both
|
|
2
|
+
// backends, so nothing past this file knows which one replied.
|
|
6
3
|
import { isObject } from "./json.js";
|
|
7
4
|
import { languageLabel } from "./language.js";
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
10
|
-
* result, a citation on a text block, a fetch result, an OpenAI action source.
|
|
11
|
-
* All four spell it `{url, title?}` (OpenAI sometimes as a bare string), all
|
|
12
|
-
* four had their own copy of this, and they disagreed about the fallback
|
|
13
|
-
* title. Keyed by url; the first real title wins over a url used as one.
|
|
14
|
-
*/
|
|
5
|
+
/** The one reader of a cited page: `{url, title?}`, or a bare string from
|
|
6
|
+
* OpenAI. Keyed by url; the first real title wins over a url used as one. */
|
|
15
7
|
export function putSource(into, value) {
|
|
16
8
|
const url = typeof value === "string"
|
|
17
9
|
? value
|
|
@@ -33,12 +33,8 @@ function retryDelay(response, attempt) {
|
|
|
33
33
|
// instant, so a fixed backoff has them all come back at the same instant too.
|
|
34
34
|
return Math.round(500 * 2 ** attempt * (0.5 + Math.random()));
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
* `retry-after` sleep of up to 20s followed by a whole further request is how a
|
|
39
|
-
* 90-second ceiling turned into two minutes. Rejects on abort; the caller
|
|
40
|
-
* reports the failure that caused the backoff, which is the useful half.
|
|
41
|
-
*/
|
|
36
|
+
/** Interruptible: the backoff is inside the caller's deadline, and a 20s
|
|
37
|
+
* `retry-after` plus another request would overrun a 90-second ceiling. */
|
|
42
38
|
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
43
39
|
if (signal?.aborted)
|
|
44
40
|
return reject(signal.reason);
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
// The language-preservation policy
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// query answers a question nobody asked — so the policy is one file, and the
|
|
5
|
-
// audit that checks it reads from the same one.
|
|
1
|
+
// The language-preservation policy: what the model is told to search in, and
|
|
2
|
+
// the audit that checks it read from the same file. A hosted search that
|
|
3
|
+
// quietly translates a Chinese query answers a question nobody asked.
|
|
6
4
|
export function searchPrompt(query, mode) {
|
|
7
5
|
const policy = mode === "preserve"
|
|
8
6
|
? "Use only the original language. Later searches may refine wording in that language, but must not translate or transliterate it."
|
|
@@ -18,22 +16,14 @@ export function searchPrompt(query, mode) {
|
|
|
18
16
|
"Do not describe your process.",
|
|
19
17
|
].join("\n");
|
|
20
18
|
}
|
|
21
|
-
/**
|
|
22
|
-
*
|
|
23
|
-
* not the test — OpenAI's hosted search always composes its own wording, and
|
|
24
|
-
* demanding an exact match there would buy a second search on every call.
|
|
25
|
-
*/
|
|
19
|
+
/** Verbatim echo is not the test: OpenAI's hosted search always composes its
|
|
20
|
+
* own wording, and an exact match would buy a second search on every call. */
|
|
26
21
|
export function preservesLanguage(query, searched) {
|
|
27
22
|
return searched !== undefined && languageLabel(searched) === languageLabel(query);
|
|
28
23
|
}
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* Kana before Han, because Japanese is mostly Han characters and the reverse
|
|
33
|
-
* order labelled 「東京 の天気」 Chinese in the warning it printed. Kanji-only
|
|
34
|
-
* Japanese is still indistinguishable from Chinese here, and no ordering fixes
|
|
35
|
-
* that — it needs a dictionary, which this is deliberately not.
|
|
36
|
-
*/
|
|
24
|
+
/** A script, not a language. Kana before Han: Japanese is mostly Han
|
|
25
|
+
* characters. Kanji-only Japanese stays indistinguishable from Chinese; that
|
|
26
|
+
* needs a dictionary, which this is deliberately not. */
|
|
37
27
|
export function languageLabel(text) {
|
|
38
28
|
if (/\p{Script=Hiragana}|\p{Script=Katakana}/u.test(text))
|
|
39
29
|
return "Japanese";
|
|
@@ -61,7 +61,7 @@ function parse(data, output, model) {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
export async function webSearchViaResponses(request, prompt, options, signal,
|
|
64
|
-
/** Progress for the surface the call came from (§
|
|
64
|
+
/** Progress for the surface the call came from (§5). */
|
|
65
65
|
note) {
|
|
66
66
|
const tool = { type: "web_search" };
|
|
67
67
|
const filters = {};
|
|
@@ -9,15 +9,8 @@ const DEFAULT_MODEL = {
|
|
|
9
9
|
};
|
|
10
10
|
/** The one escape hatch: an endpoint that has neither default model. */
|
|
11
11
|
const CONFIGURED_MODEL = process.env.PIER_WEB_MODEL?.trim();
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
* backend's cheap default, then the session's own model when it happens to be
|
|
15
|
-
* on the right API. Every one of those is a model somebody named — there is
|
|
16
|
-
* deliberately no "any other model on this API" step, because the model a
|
|
17
|
-
* search runs on decides its cost, its refusals and its results, and picking
|
|
18
|
-
* an unnamed one on the user's behalf is how a search ends up on whatever
|
|
19
|
-
* unreleased id a gateway happened to list first.
|
|
20
|
-
*/
|
|
12
|
+
/** Every candidate is a model somebody named; no "any other model on this API"
|
|
13
|
+
* step, or a search ends up on whatever unreleased id a gateway listed first. */
|
|
21
14
|
function candidates(ctx, backend) {
|
|
22
15
|
const api = BACKEND_API[backend];
|
|
23
16
|
const registry = ctx.modelRegistry;
|
|
@@ -78,10 +71,8 @@ async function target(ctx, backend, model, outputTokens) {
|
|
|
78
71
|
if (!hasAuthHeader(headers))
|
|
79
72
|
throw new Error(`No ${backend} authentication resolved`);
|
|
80
73
|
const limit = typeof model.maxTokens === "number" && model.maxTokens > 0 ? model.maxTokens : 4096;
|
|
81
|
-
// Responses spends reasoning tokens out of `max_output_tokens` too
|
|
82
|
-
//
|
|
83
|
-
// the lot and return an empty answer. The caller asks for what it wants to
|
|
84
|
-
// read; this is the one place that knows which wire it goes out on.
|
|
74
|
+
// Responses spends reasoning tokens out of `max_output_tokens` too; a
|
|
75
|
+
// reasoning model can burn the lot and return an empty answer.
|
|
85
76
|
const wanted = backend === "openai" ? outputTokens * 2 : outputTokens;
|
|
86
77
|
return {
|
|
87
78
|
backend,
|
|
@@ -91,11 +82,7 @@ async function target(ctx, backend, model, outputTokens) {
|
|
|
91
82
|
maxTokens: Math.max(128, Math.min(wanted, limit)),
|
|
92
83
|
};
|
|
93
84
|
}
|
|
94
|
-
/**
|
|
95
|
-
* `capable` narrows the backends that can serve the call — web_fetch is an
|
|
96
|
-
* Anthropic-only server tool, so it passes ["anthropic"]. `requested` is the
|
|
97
|
-
* caller's explicit choice; without one both are tried in order.
|
|
98
|
-
*/
|
|
85
|
+
/** `capable`: web_fetch is an Anthropic-only server tool. */
|
|
99
86
|
export async function resolveTarget(ctx, outputTokens, capable = ["anthropic", "openai"], requested) {
|
|
100
87
|
const wanted = (requested ? [requested] : capable).filter((backend) => capable.includes(backend));
|
|
101
88
|
if (!wanted.length) {
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// The two tools as the model sees them
|
|
2
|
-
//
|
|
3
|
-
// cannot. Every parameter is context the model pays for on every turn, so the
|
|
4
|
-
// surface is deliberately small; the wire formats behind it are anthropic.ts
|
|
5
|
-
// and openai.ts, and the answer's shape is content.ts.
|
|
1
|
+
// The two tools as the model sees them. Every parameter is context the model
|
|
2
|
+
// pays for on every turn, so the surface is deliberately small.
|
|
6
3
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
7
4
|
import { Type } from "typebox";
|
|
8
5
|
import { callNativeTool } from "./anthropic.js";
|
|
@@ -15,29 +12,13 @@ const DEFAULT_CONTEXT_CHARS = 6_000;
|
|
|
15
12
|
const SEARCH_RESULTS = 8;
|
|
16
13
|
/** `mode: "full"` is "the document", not "the transcript's whole budget". */
|
|
17
14
|
const FULL_MAX_CHARS = 60_000;
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* not be the binding constraint: `DEFAULT_CONTEXT_CHARS` is what we are willing
|
|
22
|
-
* to hand back (6k characters, which is ~1.5k English tokens and ~4k Chinese
|
|
23
|
-
* ones), so a budget below that only produces briefings that stop mid-sentence.
|
|
24
|
-
* It used to be 900, half the smaller of those, and then 2k, which is the same
|
|
25
|
-
* bug in Chinese: it covered the English reading of 6k characters and cut every
|
|
26
|
-
* CJK briefing at the point this comment claimed was fixed. So the budget is
|
|
27
|
-
* the *larger* reading plus room for the search calls themselves. Output tokens
|
|
28
|
-
* are not the cost here either — a hosted search is worth an order of magnitude
|
|
29
|
-
* more than the prose about it — and a truncated answer is paid for twice.
|
|
30
|
-
*/
|
|
15
|
+
/** Must cover the CJK reading of `DEFAULT_CONTEXT_CHARS` (6k chars ≈ 4k
|
|
16
|
+
* Chinese tokens, ~1.5k English) plus the search calls, or briefings stop
|
|
17
|
+
* mid-sentence. A hosted search costs an order of magnitude more than the prose. */
|
|
31
18
|
const SEARCH_TOKENS = 4_500;
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* model may generate, and how much of the digest reaches the caller. They were
|
|
36
|
-
* two tool parameters (`max_context_chars`, `max_content_tokens`) that only
|
|
37
|
-
* ever restated the mode, and every parameter is read by the model on every
|
|
38
|
-
* turn. `full` returns the document itself, so its digest budget is an
|
|
39
|
-
* acknowledgement — generation nobody reads.
|
|
40
|
-
*/
|
|
19
|
+
/** One dial: `mode` decides all three sizes, since separate parameters only
|
|
20
|
+
* ever restated it. `full` returns the document, so its digest budget is an
|
|
21
|
+
* acknowledgement nobody reads. */
|
|
41
22
|
const FETCH_LIMITS = {
|
|
42
23
|
concise: { fetch: 10_000, generate: 1_200, digest: 6_000 },
|
|
43
24
|
thorough: { fetch: 25_000, generate: 3_500, digest: 12_000 },
|
|
@@ -53,14 +34,8 @@ function clampText(text, maxChars) {
|
|
|
53
34
|
return text;
|
|
54
35
|
return `${text.slice(0, maxChars).trimEnd()}\n\n[truncated ${text.length - maxChars} characters]`;
|
|
55
36
|
}
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
* not one: three attempts, times up to three continuation rounds, times a
|
|
59
|
-
* language-audit retry, is tens of minutes — and Pi puts no timeout of its own
|
|
60
|
-
* on a custom tool, so that is a turn held open with nothing to show. An
|
|
61
|
-
* aborted caller signal already stops the retry loop, so this is the only
|
|
62
|
-
* thing needed to bound it.
|
|
63
|
-
*/
|
|
37
|
+
/** The per-request timeout is not a ceiling: attempts × continuation rounds ×
|
|
38
|
+
* the audit retry is tens of minutes, and Pi puts no timeout on a custom tool. */
|
|
64
39
|
const CALL_CEILING_MS = 90_000;
|
|
65
40
|
const ceiling = (signal) => {
|
|
66
41
|
const own = AbortSignal.timeout(CALL_CEILING_MS);
|
|
@@ -78,11 +53,6 @@ async function runSearch(run) {
|
|
|
78
53
|
const result = await callNativeTool(target, "web_search", prompt, { maxUses, ...domains }, signal, note);
|
|
79
54
|
return searchOutcomeFrom(result.content, result.model, target.backend, result);
|
|
80
55
|
}
|
|
81
|
-
// Every parameter here is read by the model on every turn it might search, so
|
|
82
|
-
// each one is a standing cost. `max_uses` and `max_results` were knobs nobody
|
|
83
|
-
// turned: the first did nothing on the OpenAI backend and had to say so out
|
|
84
|
-
// loud in its own results, and the second only sliced a list the caller can
|
|
85
|
-
// read the whole of.
|
|
86
56
|
export const webSearch = defineTool({
|
|
87
57
|
name: "web_search",
|
|
88
58
|
label: "Web Search",
|
|
@@ -116,11 +86,8 @@ export const webSearch = defineTool({
|
|
|
116
86
|
let outcome = await runSearch({ ...run, mode, maxUses: searchRounds(mode) });
|
|
117
87
|
const wantedLanguage = languageLabel(params.query);
|
|
118
88
|
const strayed = (o) => o.queries.filter((q) => !preservesLanguage(params.query, q.query)).map((q) => q.query);
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// stays in the language, so it audits all of them; auto and expand buy
|
|
122
|
-
// English supplements on purpose, so there the first query still decides
|
|
123
|
-
// and the strays are named in `details` instead of warned about.
|
|
89
|
+
// `preserve` promised every search stays in the language; auto and expand
|
|
90
|
+
// buy English supplements on purpose, so there only the first query decides.
|
|
124
91
|
const inLanguage = (o, auditAll) => auditAll
|
|
125
92
|
? o.queries.length > 0 && strayed(o).length === 0
|
|
126
93
|
: preservesLanguage(params.query, o.queries[0]?.query);
|
|
@@ -147,7 +114,7 @@ export const webSearch = defineTool({
|
|
|
147
114
|
: "";
|
|
148
115
|
// A briefing that stopped at the output ceiling reads exactly like a
|
|
149
116
|
// finished one; the caller decides whether to ask again, but only if it
|
|
150
|
-
// is told (§
|
|
117
|
+
// is told (§5).
|
|
151
118
|
const cut = outcome.truncated
|
|
152
119
|
? "Warning: the briefing hit the search model's output limit and stops mid-sentence."
|
|
153
120
|
: "";
|
|
@@ -240,12 +207,9 @@ export const webFetch = defineTool({
|
|
|
240
207
|
: document.text
|
|
241
208
|
? clampText(document.text, limits.digest)
|
|
242
209
|
: "Fetch completed.";
|
|
243
|
-
// `full`
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// copy is on disk either way, and the note below points at it. A question
|
|
247
|
-
// is still answered, above the document; "OK" is not, it is the receipt
|
|
248
|
-
// for a digest we asked it not to write.
|
|
210
|
+
// `full` still has a ceiling: 100k tokens is a context nobody can afford,
|
|
211
|
+
// and the whole copy is on disk. "OK" is the receipt for a digest we
|
|
212
|
+
// asked it not to write, not an answer.
|
|
249
213
|
const output = mode !== "full" ? distilled : [
|
|
250
214
|
question && answer ? clampText(answer, FETCH_LIMITS.concise.digest) : "",
|
|
251
215
|
document.text ? clampText(document.text, limits.digest) : "",
|
|
@@ -291,17 +255,9 @@ function parsePublicUrl(value) {
|
|
|
291
255
|
url.hash = "";
|
|
292
256
|
return url;
|
|
293
257
|
}
|
|
294
|
-
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* returned result (`agent-loop.js`: `return { result, isError: false }`). These
|
|
298
|
-
* tools used to return one, so every refusal they reported — a bad URL, a dead
|
|
299
|
-
* endpoint, a hosted tool that never ran — was recorded as a success with an
|
|
300
|
-
* apology in it.
|
|
301
|
-
*
|
|
302
|
-
* Our own ceiling also looks like a cancellation from the outside; say which one
|
|
303
|
-
* it was, or the caller reads "aborted" and cannot tell whether it did that.
|
|
304
|
-
*/
|
|
258
|
+
/** Throwing is the only way to report a failed tool call: Pi ignores an
|
|
259
|
+
* `isError` field in a returned result (`agent-loop.js`). Our own ceiling
|
|
260
|
+
* looks like a cancellation from outside, so say which one it was. */
|
|
305
261
|
function fail(error, until, caller) {
|
|
306
262
|
const message = error instanceof Error ? error.message : String(error);
|
|
307
263
|
const gaveUp = until?.aborted && !caller?.aborted;
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// One Pier per instance directory: the ownership claim, taken before anything
|
|
2
|
+
// under PIER_HOME is opened. A pid file, because Node's stdlib has no advisory
|
|
3
|
+
// locking and the pid is what makes a crashed holder's claim reclaimable.
|
|
4
|
+
import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { PIER_LOCK } from "./paths.js";
|
|
7
|
+
/** Unreadable, gone, or not a pid: no holder anyone can name, so the file is
|
|
8
|
+
* not evidence of one. */
|
|
9
|
+
function holder(file) {
|
|
10
|
+
try {
|
|
11
|
+
const pid = Number(readFileSync(file, "utf8").trim());
|
|
12
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** EPERM is a live process owned by someone else; only ESRCH proves it is gone. */
|
|
19
|
+
function alive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return err.code === "EPERM";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The pid is written to a private file and hard-linked into place: the link
|
|
29
|
+
* either creates the lock with the pid already in it or fails EEXIST, so no
|
|
30
|
+
* racer can ever read an empty lock. */
|
|
31
|
+
function take(path) {
|
|
32
|
+
const mine = `${path}.${String(process.pid)}`;
|
|
33
|
+
writeFileSync(mine, `${String(process.pid)}\n`, { mode: 0o600 });
|
|
34
|
+
try {
|
|
35
|
+
linkSync(mine, path);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err.code === "EEXIST")
|
|
39
|
+
return null;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
rmSync(mine, { force: true });
|
|
44
|
+
}
|
|
45
|
+
// Only ours: a stale-lock takeover elsewhere may have replaced the file.
|
|
46
|
+
return {
|
|
47
|
+
release: () => {
|
|
48
|
+
if (holder(path) === process.pid)
|
|
49
|
+
rmSync(path, { force: true });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Moves the stale file aside under a name only this pid uses — never an unlink
|
|
54
|
+
* in place — then checks it moved the inode it read; a racer's fresh claim that
|
|
55
|
+
* landed in between goes straight back. ENOENT is the other reclaimer winning. */
|
|
56
|
+
function reclaim(path, fd) {
|
|
57
|
+
const aside = `${path}.${String(process.pid)}.stale`;
|
|
58
|
+
try {
|
|
59
|
+
renameSync(path, aside);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err.code === "ENOENT")
|
|
63
|
+
return;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
if (statSync(aside, { bigint: true }).ino === fstatSync(fd, { bigint: true }).ino)
|
|
67
|
+
rmSync(aside, { force: true });
|
|
68
|
+
else
|
|
69
|
+
renameSync(aside, path);
|
|
70
|
+
}
|
|
71
|
+
export function acquireInstanceLock(path = PIER_LOCK) {
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
73
|
+
const first = take(path);
|
|
74
|
+
if (first)
|
|
75
|
+
return first;
|
|
76
|
+
// Held open across the reclaim: an open inode cannot be reused by a new file,
|
|
77
|
+
// so the comparison in `reclaim` is exact.
|
|
78
|
+
let fd;
|
|
79
|
+
try {
|
|
80
|
+
fd = openSync(path, "r");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
fd = undefined;
|
|
84
|
+
}
|
|
85
|
+
if (fd !== undefined) {
|
|
86
|
+
try {
|
|
87
|
+
const pid = holder(fd);
|
|
88
|
+
if (pid !== null && alive(pid))
|
|
89
|
+
return { heldBy: pid };
|
|
90
|
+
reclaim(path, fd);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// The retry can only lose to another start that claimed the same directory.
|
|
97
|
+
return take(path) ?? { heldBy: holder(path) };
|
|
98
|
+
}
|