@timqi/pier 0.0.9 → 0.0.16
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 +7 -1
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +190 -31
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +72 -0
- package/dist/db.js +78 -0
- package/dist/extensions/index.js +5 -2
- package/dist/extensions/web/artifacts.js +7 -2
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +33 -8
- package/dist/limits.js +14 -0
- package/dist/main.js +47 -6
- package/dist/paths.js +6 -1
- package/dist/settings.js +44 -0
- package/dist/tasks/agent.js +23 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +60 -12
- package/dist/tasks/execution.js +9 -1
- package/dist/tasks/groups.js +8 -4
- package/dist/tasks/messages.js +10 -2
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +7 -2
- package/dist/tasks/service.js +22 -6
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/tool.js +0 -12
- package/dist/tasks/types.js +4 -0
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +165 -36
- package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
- package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
- package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +22 -7
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +145 -64
- package/dist/web/session-state.js +33 -51
- package/dist/web/types.js +5 -0
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
- package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// What a provider's answer becomes on the way to the model: sources, results,
|
|
2
|
+
// the queries actually searched, usage, and the text of a fetched document.
|
|
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
6
|
import { isObject } from "./json.js";
|
|
2
7
|
import { languageLabel } from "./language.js";
|
|
3
8
|
/**
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// The language-preservation policy in words: what the model is told to search
|
|
2
|
+
// in, and how to tell afterwards whether it did. This is the reason the
|
|
3
|
+
// extension exists at all — a hosted search that quietly translates a Chinese
|
|
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
6
|
export function searchPrompt(query, mode) {
|
|
2
7
|
const policy = mode === "preserve"
|
|
3
8
|
? "Use only the original language. Later searches may refine wording in that language, but must not translate or transliterate it."
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// The two tools as the model sees them: web_search and web_fetch — their
|
|
2
|
+
// parameters, which backend answers a call, and what comes back when one
|
|
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
6
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
7
|
import { Type } from "typebox";
|
|
3
8
|
import { callNativeTool } from "./anthropic.js";
|
|
@@ -16,11 +21,14 @@ const FULL_MAX_CHARS = 60_000;
|
|
|
16
21
|
* not be the binding constraint: `DEFAULT_CONTEXT_CHARS` is what we are willing
|
|
17
22
|
* to hand back (6k characters, which is ~1.5k English tokens and ~4k Chinese
|
|
18
23
|
* ones), so a budget below that only produces briefings that stop mid-sentence.
|
|
19
|
-
* It used to be 900, half the smaller of those
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
30
|
*/
|
|
23
|
-
const SEARCH_TOKENS =
|
|
31
|
+
const SEARCH_TOKENS = 4_500;
|
|
24
32
|
/**
|
|
25
33
|
* Same rule for a fetch, and one dial for it: `mode` says how much of the page
|
|
26
34
|
* matters, so it decides all three sizes — what the provider fetches, what the
|
|
@@ -107,23 +115,36 @@ export const webSearch = defineTool({
|
|
|
107
115
|
const run = { ctx, query: params.query, domains, backend: params.backend, signal: until, note };
|
|
108
116
|
let outcome = await runSearch({ ...run, mode, maxUses: searchRounds(mode) });
|
|
109
117
|
const wantedLanguage = languageLabel(params.query);
|
|
110
|
-
const
|
|
111
|
-
|
|
118
|
+
const strayed = (o) => o.queries.filter((q) => !preservesLanguage(params.query, q.query)).map((q) => q.query);
|
|
119
|
+
// The prompt pins the first query verbatim, so auditing only that one
|
|
120
|
+
// audits the query that cannot fail. `preserve` promised every search
|
|
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.
|
|
124
|
+
const inLanguage = (o, auditAll) => auditAll
|
|
125
|
+
? o.queries.length > 0 && strayed(o).length === 0
|
|
126
|
+
: preservesLanguage(params.query, o.queries[0]?.query);
|
|
127
|
+
let preserved = inLanguage(outcome, mode === "preserve");
|
|
112
128
|
if (outcome.queries.length && !preserved) {
|
|
113
129
|
note(`the backend left ${wantedLanguage} — searching again, that language only`);
|
|
114
130
|
const retried = await runSearch({ ...run, mode: "preserve", maxUses: 1 });
|
|
115
131
|
// Only if it worked. The retry is one narrowed search against the
|
|
116
132
|
// first's three rounds, so a retry that *also* leaves the language is
|
|
117
133
|
// a worse answer, and swapping it in spent a search to get there.
|
|
118
|
-
if (inLanguage(retried)) {
|
|
134
|
+
if (inLanguage(retried, true)) {
|
|
119
135
|
outcome = retried;
|
|
120
136
|
preserved = true;
|
|
121
137
|
}
|
|
122
138
|
}
|
|
123
139
|
const auditAvailable = outcome.queries.length > 0;
|
|
140
|
+
const offLanguage = strayed(outcome);
|
|
141
|
+
// No query metadata means the audit never ran — under `preserve`, the one
|
|
142
|
+
// mode that promised it, an unaudited answer must not read like a clean one.
|
|
124
143
|
const warning = auditAvailable && !preserved
|
|
125
144
|
? `Warning: the search backend translated the query out of ${wantedLanguage} despite strict preservation.`
|
|
126
|
-
: ""
|
|
145
|
+
: mode === "preserve" && !auditAvailable
|
|
146
|
+
? "Note: the backend returned no query metadata, so strict language preservation could not be audited."
|
|
147
|
+
: "";
|
|
127
148
|
// A briefing that stopped at the output ceiling reads exactly like a
|
|
128
149
|
// finished one; the caller decides whether to ask again, but only if it
|
|
129
150
|
// is told (§5b).
|
|
@@ -153,6 +174,10 @@ export const webSearch = defineTool({
|
|
|
153
174
|
languageMode: mode,
|
|
154
175
|
queries: outcome.queries,
|
|
155
176
|
queryLanguagePreserved: auditAvailable ? preserved : undefined,
|
|
177
|
+
// Legitimate under auto/expand, so not a warning — but the caller
|
|
178
|
+
// cannot weigh a briefing built partly from English searches if it
|
|
179
|
+
// is never told which searches those were.
|
|
180
|
+
queriesOffLanguage: offLanguage.length ? offLanguage : undefined,
|
|
156
181
|
originalQueryVerbatim: auditAvailable
|
|
157
182
|
? outcome.queries[0]?.query === params.query
|
|
158
183
|
: undefined,
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The numbers more than one area has to agree on.
|
|
2
|
+
//
|
|
3
|
+
// Not policy or behaviour — a value several modules must spell the same way,
|
|
4
|
+
// and a wrong copy makes two surfaces disagree about one session: a title
|
|
5
|
+
// truncated to a different length depending on which path derived it.
|
|
6
|
+
//
|
|
7
|
+
// Here because a leaf may be imported by every area and depends on nothing
|
|
8
|
+
// itself, which is the only shape that fits: agent/ derives a title and must
|
|
9
|
+
// not import core/, web/ derives one too and must not import agent/, and the
|
|
10
|
+
// browser needs the same numbers with no runtime behind them.
|
|
11
|
+
/** How much of a message becomes a title, wherever one is derived: the listing
|
|
12
|
+
* reading a transcript (agent/listing.ts), a rename's fallback (agent/pi.ts),
|
|
13
|
+
* the fill at first prompt and the rename boundary (web/). */
|
|
14
|
+
export const SESSION_TITLE_MAX = 80;
|
package/dist/main.js
CHANGED
|
@@ -14,7 +14,7 @@ import { registerChannelRoutes } from "./channels/routes.js";
|
|
|
14
14
|
import { ChannelRuntime } from "./channels/runtime.js";
|
|
15
15
|
import { SlackApi } from "./channels/slack-api.js";
|
|
16
16
|
import { SlackDirectory } from "./channels/slack-directory.js";
|
|
17
|
-
import { handleSlackTool, slackToolSpec } from "./channels/slack-tool.js";
|
|
17
|
+
import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
|
|
18
18
|
import { parseConversation as parseSlackConversation } from "./channels/slack.js";
|
|
19
19
|
import { EventHub } from "./core/hub.js";
|
|
20
20
|
import { pierDb } from "./db.js";
|
|
@@ -28,6 +28,8 @@ import { TaskService } from "./tasks/service.js";
|
|
|
28
28
|
import { TaskStore } from "./tasks/store.js";
|
|
29
29
|
import { taskToolSpec } from "./tasks/tool.js";
|
|
30
30
|
import { PIER_HOME, pierPath, resolveAgentDir } from "./paths.js";
|
|
31
|
+
import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath } from "./tools.js";
|
|
32
|
+
import { toolsTask } from "./tools-task.js";
|
|
31
33
|
import { Secrets } from "./secrets.js";
|
|
32
34
|
import { startUpdate, unitPath, updaterProblem } from "./service.js";
|
|
33
35
|
import { SettingsStore } from "./settings.js";
|
|
@@ -52,6 +54,10 @@ const log = logger("pier");
|
|
|
52
54
|
// again from this instance's own PIER_HOME.
|
|
53
55
|
process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
|
|
54
56
|
process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
|
|
57
|
+
// Ahead of everything Pier spawns — sessions, tasks, the Web Terminal all
|
|
58
|
+
// inherit this process's env. A tool switched on in the Console is Pier's
|
|
59
|
+
// copy at Pier's version, so it goes first, not last.
|
|
60
|
+
prependPath(process.env);
|
|
55
61
|
// First, and explicitly: every store below shares this one connection, and a
|
|
56
62
|
// schema that cannot be migrated must stop the process here — before a port is
|
|
57
63
|
// open and before anything has written a row.
|
|
@@ -92,8 +98,9 @@ const factory = new PiAgentFactory([
|
|
|
92
98
|
const config = channelStore.get("slack");
|
|
93
99
|
return config.token ? new SlackApi(config.token, config.appToken) : null;
|
|
94
100
|
},
|
|
95
|
-
// Which Slack thread this session is answering, so "post here" needs
|
|
96
|
-
// ids. Looked up per call: the mapping is durable, the session is
|
|
101
|
+
// Which Slack thread this session is answering, so "post here" needs
|
|
102
|
+
// no ids. Looked up per call: the mapping is durable, the session is
|
|
103
|
+
// not.
|
|
97
104
|
here: (sessionId) => {
|
|
98
105
|
const key = router.conversationOf(sessionId);
|
|
99
106
|
if (key?.channelId !== "slack")
|
|
@@ -102,7 +109,10 @@ const factory = new PiAgentFactory([
|
|
|
102
109
|
return channel && threadTs ? { channel, threadTs } : null;
|
|
103
110
|
},
|
|
104
111
|
log: (m) => logger("slack.tool").warn(m),
|
|
105
|
-
}, params, callerSessionId)
|
|
112
|
+
}, params, callerSessionId),
|
|
113
|
+
// No Slack, no schema: an unconfigured tool would sit in every prompt of
|
|
114
|
+
// every session and be able to answer nothing.
|
|
115
|
+
() => slackToolAvailable(channelStore)),
|
|
106
116
|
],
|
|
107
117
|
// Called per session open, so a setting changed in the Console reaches the
|
|
108
118
|
// next session without a restart.
|
|
@@ -135,6 +145,16 @@ tasks = new TaskService(new TaskStore(db), factory, router, hub, {
|
|
|
135
145
|
modelMenu: () => settings.get().modelMenu,
|
|
136
146
|
});
|
|
137
147
|
tasks.start();
|
|
148
|
+
// The managed CLI tools (src/tools.ts), and the daily task that keeps them
|
|
149
|
+
// current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
|
|
150
|
+
// wired here because tools.ts may not import tasks/.
|
|
151
|
+
const managedTools = new ManagedTools();
|
|
152
|
+
const toolsUpdate = toolsTask(tasks);
|
|
153
|
+
// Before any route exists: two first flips could otherwise both find no task
|
|
154
|
+
// and create one each. A failure here is logged, and the next flip retries.
|
|
155
|
+
const reconciled = await toolsUpdate.reconcile();
|
|
156
|
+
if ("problem" in reconciled)
|
|
157
|
+
log.error(`tools cannot be managed: ${reconciled.problem}`);
|
|
138
158
|
channelStore = new ChannelStore(db, secrets);
|
|
139
159
|
const control = createControl({ router, factory, conversations, store: channelStore });
|
|
140
160
|
const channels = new ChannelRuntime(channelStore, router, control);
|
|
@@ -283,7 +303,7 @@ registerPushRoutes(app, {
|
|
|
283
303
|
hub,
|
|
284
304
|
unread: (id) => sessionState.unread(id),
|
|
285
305
|
channelOf: (id) => router.conversationOf(id)?.channelId,
|
|
286
|
-
|
|
306
|
+
summary: (id) => factory.find(id),
|
|
287
307
|
publicUrl: () => settings.get().publicUrl,
|
|
288
308
|
});
|
|
289
309
|
app.route("/", createServer({
|
|
@@ -296,7 +316,28 @@ app.route("/", createServer({
|
|
|
296
316
|
settings,
|
|
297
317
|
// The catalog is code, so the composition root is where it is read: web/
|
|
298
318
|
// gets names and summaries, not a module that imports the Pi SDK.
|
|
299
|
-
|
|
319
|
+
// One list, assembled where both halves are visible: the extensions Pier
|
|
320
|
+
// loads from inside itself and the binaries it installs are the same kind of
|
|
321
|
+
// switch to the person flipping it, and rtk is both.
|
|
322
|
+
catalog: async () => {
|
|
323
|
+
const { extensions, tools, customTools } = settings.get();
|
|
324
|
+
return {
|
|
325
|
+
entries: [...bundledInfo(extensions), ...await managedTools.status(tools, customTools)],
|
|
326
|
+
toolsTaskId: toolsUpdate.id(),
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
// Names only, and the same two lists the catalog above is built from: the
|
|
330
|
+
// route validates a switch against what this Pier *can* switch, which is
|
|
331
|
+
// code, never against a catalog whose custom half the request may be
|
|
332
|
+
// rewriting.
|
|
333
|
+
names: { extensions: bundledInfo([]).map((entry) => entry.name), tools: MANAGED.map((tool) => tool.name) },
|
|
334
|
+
onToolsChanged: toolsUpdate.changed,
|
|
335
|
+
// The rule lives with the installer; the names the bundled catalog already
|
|
336
|
+
// owns live with the extensions. Only here are both in scope.
|
|
337
|
+
validateCustomTools: (raw) => {
|
|
338
|
+
const validated = normalizeCustomTools(raw, bundledInfo([]).map((entry) => entry.name));
|
|
339
|
+
return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
|
|
340
|
+
},
|
|
300
341
|
secrets,
|
|
301
342
|
updates,
|
|
302
343
|
updater,
|
package/dist/paths.js
CHANGED
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
// depend on and that depends on nothing.
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
/** Empty is unset, not a value: `PIER_HOME=` in a shell would otherwise
|
|
11
|
+
* resolve every path below relative to the working directory, and the
|
|
12
|
+
* database, the boards and the master key would land wherever the process
|
|
13
|
+
* happened to start. Pure, because that rule is worth a test. */
|
|
14
|
+
export const resolveHome = (value, home = homedir()) => value || join(home, ".pier");
|
|
10
15
|
/** `$PIER_HOME`, or `~/.pier`. Fixed for the life of the process. */
|
|
11
|
-
export const PIER_HOME = process.env.PIER_HOME
|
|
16
|
+
export const PIER_HOME = resolveHome(process.env.PIER_HOME);
|
|
12
17
|
/** A path inside it — `pierPath("boards")`. */
|
|
13
18
|
export const pierPath = (...parts) => join(PIER_HOME, ...parts);
|
|
14
19
|
/** The one SQLite file; every store opens this same path. In its own
|
package/dist/settings.js
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
import { isThinkingLevel } from "./core/types.js";
|
|
10
10
|
import { pierDb } from "./db.js";
|
|
11
11
|
import { logger } from "./log.js";
|
|
12
|
+
// The one place the custom-tool vocabulary lives (names, ubix sources, the
|
|
13
|
+
// names Pier already owns). Imported rather than copied: a second validator
|
|
14
|
+
// would be the third-copy bug one release later, and tools.ts is root-layer
|
|
15
|
+
// like this file, so nothing crosses a seam.
|
|
16
|
+
import { normalizeCustomTools } from "./tools.js";
|
|
12
17
|
const log = logger("settings");
|
|
13
18
|
/**
|
|
14
19
|
* `""` clears it, `null` rejects it. Rejecting rather than repairing: a
|
|
@@ -94,6 +99,15 @@ export function normalizeTerminalInitCommand(raw) {
|
|
|
94
99
|
* setting it cannot currently explain.
|
|
95
100
|
*/
|
|
96
101
|
export function normalizeExtensions(raw) {
|
|
102
|
+
return normalizeNames(raw);
|
|
103
|
+
}
|
|
104
|
+
/** Same shape, same contract, same cap — the managed-tool set (src/tools.ts).
|
|
105
|
+
* Shape only again: tools.ts owns the catalog, and a name it does not know is
|
|
106
|
+
* ignored there rather than rejected here, so a downgrade cannot lose one. */
|
|
107
|
+
export function normalizeTools(raw) {
|
|
108
|
+
return normalizeNames(raw);
|
|
109
|
+
}
|
|
110
|
+
function normalizeNames(raw) {
|
|
97
111
|
if (!Array.isArray(raw) || raw.length > 32)
|
|
98
112
|
return null;
|
|
99
113
|
const names = new Set();
|
|
@@ -119,6 +133,8 @@ export class SettingsStore {
|
|
|
119
133
|
autoUpdate: this.#value("autoUpdate") === "1",
|
|
120
134
|
terminalInitCommand: this.#value("terminalInitCommand") ?? "",
|
|
121
135
|
extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
|
|
136
|
+
tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
|
|
137
|
+
customTools: this.#json("customTools", normalizeCustomTools, "a list of {name, spec}") ?? [],
|
|
122
138
|
};
|
|
123
139
|
}
|
|
124
140
|
/**
|
|
@@ -168,6 +184,34 @@ export class SettingsStore {
|
|
|
168
184
|
this.#set("extensions", JSON.stringify(names));
|
|
169
185
|
return this.get();
|
|
170
186
|
}
|
|
187
|
+
/** Same contract again: hand this `normalizeTools`'s output. */
|
|
188
|
+
setTools(names) {
|
|
189
|
+
this.#set("tools", JSON.stringify(names));
|
|
190
|
+
return this.get();
|
|
191
|
+
}
|
|
192
|
+
/** Same contract again: hand this `normalizeCustomTools`'s output. */
|
|
193
|
+
setCustomTools(tools) {
|
|
194
|
+
this.#set("customTools", JSON.stringify(tools));
|
|
195
|
+
return this.get();
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Several setters as one write. A request that declares a tool *and* the
|
|
199
|
+
* switch that turns it on must not be able to store one without the other:
|
|
200
|
+
* half of that pair is a switch nobody can explain — on and undeclared, or
|
|
201
|
+
* declared and invisible.
|
|
202
|
+
*/
|
|
203
|
+
transact(work) {
|
|
204
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
205
|
+
try {
|
|
206
|
+
const result = work();
|
|
207
|
+
this.#db.exec("COMMIT");
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
this.#db.exec("ROLLBACK");
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
171
215
|
#set(key, value) {
|
|
172
216
|
this.#db.prepare(`
|
|
173
217
|
INSERT INTO settings(key, value) VALUES (?, ?)
|
package/dist/tasks/agent.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
// A run that *is* a Pi session: which session it opens (reuse, fresh, fork),
|
|
2
|
+
// what the child is told before the prompt, and how many may run at once. The
|
|
3
|
+
// concurrency caps are here rather than in execution.ts because they bound
|
|
4
|
+
// agents specifically — a bash run costs a process, an agent run costs a
|
|
5
|
+
// model's context and someone's rate limit.
|
|
1
6
|
import { quietLabel, splitReply } from "../core/reply.js";
|
|
2
7
|
import { Router } from "../core/router.js";
|
|
8
|
+
import { runSource } from "./callbacks.js";
|
|
3
9
|
import { TaskMessenger } from "./messages.js";
|
|
4
10
|
import { TaskStore } from "./store.js";
|
|
5
11
|
const MAX_ACTIVE_AGENTS = 4;
|
|
@@ -44,6 +50,12 @@ export class AgentTaskRunner {
|
|
|
44
50
|
const session = await this.resolveSession(run, action);
|
|
45
51
|
return await this.withSession(session.id, async () => {
|
|
46
52
|
await this.waitUntilIdle(session, signal);
|
|
53
|
+
// Task requests come seconds apart — the 1h Anthropic cache-write premium
|
|
54
|
+
// never earns its 2× back, so task runs use the 5m TTL. Set here, not in
|
|
55
|
+
// resolveSession: this covers create, fork, reuse and resume alike, on
|
|
56
|
+
// every attempt — and only after the session is idle, so a reused
|
|
57
|
+
// interactive session's in-flight turn keeps its 1h writes.
|
|
58
|
+
session.setCacheRetention("short");
|
|
47
59
|
start();
|
|
48
60
|
// No input is no block: `<task_input>\nnull\n</task_input>` is four
|
|
49
61
|
// lines telling the agent nothing, on every run that has no input.
|
|
@@ -55,6 +67,10 @@ export class AgentTaskRunner {
|
|
|
55
67
|
const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
|
|
56
68
|
run.context.sessionId = session.id;
|
|
57
69
|
run.context.model = session.model;
|
|
70
|
+
// The level the session settled on, not the one the task asked for:
|
|
71
|
+
// an unspecified effort inherits the caller's, and the card in the
|
|
72
|
+
// subagent's own transcript should say which one that was.
|
|
73
|
+
run.context.thinking = session.thinkingLevel;
|
|
58
74
|
run.context.renderedPrompt = prompt;
|
|
59
75
|
this.store.saveRun(run);
|
|
60
76
|
let text = "";
|
|
@@ -83,6 +99,7 @@ export class AgentTaskRunner {
|
|
|
83
99
|
taskId: run.taskId,
|
|
84
100
|
runId: run.id,
|
|
85
101
|
sourceSessionId: run.sourceSessionId,
|
|
102
|
+
source: runSource(run),
|
|
86
103
|
}, "prompt");
|
|
87
104
|
await Promise.resolve();
|
|
88
105
|
this.messages.deliverPendingControls(run);
|
|
@@ -102,6 +119,10 @@ export class AgentTaskRunner {
|
|
|
102
119
|
return { type: "agent", text: reply.text || quietLabel(reply.silence), sessionId: session.id };
|
|
103
120
|
}
|
|
104
121
|
finally {
|
|
122
|
+
// The run is what earns "short"; a reused interactive session goes
|
|
123
|
+
// back to chat afterwards. For task-created sessions this is moot —
|
|
124
|
+
// idle until the next run downgrades them again.
|
|
125
|
+
session.setCacheRetention("long");
|
|
105
126
|
signal.removeEventListener("abort", abort);
|
|
106
127
|
unsubscribe();
|
|
107
128
|
}
|
|
@@ -115,9 +136,8 @@ export class AgentTaskRunner {
|
|
|
115
136
|
if (run.targetSessionId) {
|
|
116
137
|
return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
|
|
117
138
|
}
|
|
118
|
-
const listed = await this.factory.list();
|
|
119
139
|
const source = run.sourceSessionId
|
|
120
|
-
?
|
|
140
|
+
? await this.factory.find(run.sourceSessionId)
|
|
121
141
|
: undefined;
|
|
122
142
|
const policy = action.session;
|
|
123
143
|
let cwd;
|
|
@@ -130,7 +150,7 @@ export class AgentTaskRunner {
|
|
|
130
150
|
cwd = policy.cwd;
|
|
131
151
|
}
|
|
132
152
|
else if (policy.mode === "reuse") {
|
|
133
|
-
cwd =
|
|
153
|
+
cwd = (await this.factory.find(policy.sessionId))?.cwd ?? "";
|
|
134
154
|
}
|
|
135
155
|
else {
|
|
136
156
|
cwd = policy.cwd ?? source?.cwd ?? "";
|
|
@@ -145,7 +165,6 @@ export class AgentTaskRunner {
|
|
|
145
165
|
model: action.launch?.model ??
|
|
146
166
|
(run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
|
|
147
167
|
thinking: action.launch?.thinking,
|
|
148
|
-
capabilities: action.launch?.capabilities,
|
|
149
168
|
};
|
|
150
169
|
const session = run.sessionMode === "fork"
|
|
151
170
|
? await this.factory.fork(run.sourceSessionId, opts)
|
package/dist/tasks/callbacks.js
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
import { Router } from "../core/router.js";
|
|
4
4
|
import { Outbox } from "./outbox.js";
|
|
5
5
|
import { TaskStore } from "./store.js";
|
|
6
|
+
/** How a run is named to the session that gets its result: the run id, and the
|
|
7
|
+
* session that did the work — a relayer's next move is a deep link to it
|
|
8
|
+
* (`#/session/<id>`), and without this line that costs a second tool call.
|
|
9
|
+
* Shared with the group callback, which has always carried both. */
|
|
10
|
+
export const runRef = (run) => `Run: ${run.id}${run.targetSessionId ? ` / Session: ${run.targetSessionId}` : ""}`;
|
|
11
|
+
/** What the card in the recipient's transcript says the input came from. Part
|
|
12
|
+
* of the run vocabulary for the same reason `runRef` is: the callback, the
|
|
13
|
+
* subagent messages and the delegation prompt all name a run, and three
|
|
14
|
+
* spellings of "which task, on what model" would be three cards. */
|
|
15
|
+
export const runSource = (run) => ({
|
|
16
|
+
taskName: run.context.definition.name,
|
|
17
|
+
...(run.context.model ? { model: run.context.model } : {}),
|
|
18
|
+
...(run.context.thinking ? { thinking: run.context.thinking } : {}),
|
|
19
|
+
});
|
|
6
20
|
export function runResultText(run) {
|
|
7
21
|
let result = run.error ?? "No result";
|
|
8
22
|
if (run.result?.type === "agent")
|
|
@@ -35,6 +49,11 @@ export class TaskCallbacks {
|
|
|
35
49
|
runId: runs[0].id,
|
|
36
50
|
sourceSessionId: runs[0].targetSessionId,
|
|
37
51
|
runIds: runs.map((run) => run.id),
|
|
52
|
+
// Only when the input is about one run: a batch is several tasks in
|
|
53
|
+
// one delivery, and the first one's name and model as the card's
|
|
54
|
+
// caption would attribute every other result to it. The text names
|
|
55
|
+
// each run; the card says nothing rather than something false.
|
|
56
|
+
...(runs.length === 1 ? { source: runSource(runs[0]) } : {}),
|
|
38
57
|
},
|
|
39
58
|
}),
|
|
40
59
|
describe: (run) => `the result of "${run.context.definition.name}"`,
|
|
@@ -69,7 +88,7 @@ export class TaskCallbacks {
|
|
|
69
88
|
text(runs) {
|
|
70
89
|
const sections = runs.map((run) => [
|
|
71
90
|
`Task "${run.context.definition.name}" finished with state: ${run.state}`,
|
|
72
|
-
|
|
91
|
+
runRef(run),
|
|
73
92
|
"",
|
|
74
93
|
runResultText(run),
|
|
75
94
|
].join("\n"));
|
package/dist/tasks/command.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
// A bash task's script, run in its cwd with its input on stdin. The output is
|
|
2
|
+
// capped as it arrives rather than after: a run that printed a gigabyte is a
|
|
3
|
+
// run whose result still has to fit in a row, a transcript and a callback.
|
|
1
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { logger } from "../log.js";
|
|
6
|
+
const log = logger("tasks");
|
|
2
7
|
const OUTPUT_LIMIT = 1024 * 1024;
|
|
3
8
|
class CappedOutput {
|
|
4
9
|
chunks = [];
|
|
@@ -69,6 +74,16 @@ export function runBash(script, cwd, input, signal) {
|
|
|
69
74
|
stderrTruncated: stderr.truncated,
|
|
70
75
|
});
|
|
71
76
|
});
|
|
77
|
+
// A script that never reads stdin is ordinary (`exit 0`, a one-line curl),
|
|
78
|
+
// and writing the input to a pipe nobody is holding raises EPIPE *here*.
|
|
79
|
+
// Unhandled, that is an `error` event on a stream, which is an uncaught
|
|
80
|
+
// exception, which is main.ts exiting the process: one task script could
|
|
81
|
+
// take every session and every other run down with it. The input not being
|
|
82
|
+
// wanted is not a failure of the run — anything else still gets said.
|
|
83
|
+
child.stdin.on("error", (err) => {
|
|
84
|
+
if (err.code !== "EPIPE")
|
|
85
|
+
log.warn(`run input could not be written: ${err.message}`);
|
|
86
|
+
});
|
|
72
87
|
child.stdin.end(encodedInput);
|
|
73
88
|
});
|
|
74
89
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// What a task *is* before it ever runs: the id it is minted with, the draft
|
|
2
|
+
// validated into a definition, and when its trigger is next due. Every way a
|
|
3
|
+
// definition can be created — HTTP, the task tool, Pier's own owned task —
|
|
4
|
+
// arrives here, so a field is checked in one place or nowhere.
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
2
6
|
import { stat } from "node:fs/promises";
|
|
3
7
|
import { Cron } from "croner";
|
|
4
8
|
import { isThinkingLevel } from "../core/types.js";
|
|
@@ -7,6 +11,46 @@ import { Router } from "../core/router.js";
|
|
|
7
11
|
import { TaskStore } from "./store.js";
|
|
8
12
|
const DEFAULT_TIMEOUT = 900;
|
|
9
13
|
const MIN_WATCH_SECONDS = 5;
|
|
14
|
+
/** Crockford's base32, lowercased: i, l, o and u are gone — the three that
|
|
15
|
+
* misread as 1/0 and the one that completes most accidental words — and one
|
|
16
|
+
* case throughout, so a model re-types an id it read verbatim. */
|
|
17
|
+
const ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
18
|
+
/** The one byte → symbol step, exported only so a test can walk all 256 byte
|
|
19
|
+
* values: a repeated or omitted character in the alphabet above biases every
|
|
20
|
+
* id ever minted, and no sample of finished ids can show it. */
|
|
21
|
+
export const idSymbol = (byte) => ID_ALPHABET.charAt(byte & 31);
|
|
22
|
+
/** The id every task record is minted with — runs, definitions, groups and
|
|
23
|
+
* messages alike — because these ids ride through model context constantly:
|
|
24
|
+
* every run summary, callback, get, steer and reply echoes one, and a UUID
|
|
25
|
+
* spends ~12 tokens where this spends ~6. Nothing parses or orders by them —
|
|
26
|
+
* every comparison in the area is string equality and every listing orders by
|
|
27
|
+
* a timestamp column — so rows minted as UUIDs before this keep working
|
|
28
|
+
* untouched; there is nothing to migrate. It lives here rather than in
|
|
29
|
+
* types.ts because the browser type-checks that file and it stays node-free.
|
|
30
|
+
*
|
|
31
|
+
* Sixteen characters, 80 bits (`& 31` is unbiased because 256 is a multiple
|
|
32
|
+
* of 32). Sixty would have read the same and cost a token less, but a watch
|
|
33
|
+
* task on a 5-second interval mints ~6M runs a year, and a collision here is
|
|
34
|
+
* not an error: `saveRun`'s ON CONFLICT DO UPDATE would quietly overwrite the
|
|
35
|
+
* older run with the newer one. Four more characters buy ~16 million times
|
|
36
|
+
* the headroom for four bytes — cheaper than the alternative fix, which is a
|
|
37
|
+
* strict INSERT and a retry loop on every one of the four mint sites. */
|
|
38
|
+
export const newId = () => Array.from(randomBytes(16), idSymbol).join("");
|
|
39
|
+
/**
|
|
40
|
+
* Seam decision (tasks/): a definition Pier's own code created is reconciled by
|
|
41
|
+
* that code, and edited by nobody.
|
|
42
|
+
*
|
|
43
|
+
* `creator` is `"http"` for the Console and `session:<id>` for the task tool.
|
|
44
|
+
* Anything else is an instance-layer owner — today the tools update task
|
|
45
|
+
* (src/tools-task.ts), whose script a switch in Settings runs on demand and
|
|
46
|
+
* cron runs nightly. Both
|
|
47
|
+
* public surfaces could rename it, point it at another script, pause it or
|
|
48
|
+
* archive it, and the switch would go on claiming Pier keeps the tools current
|
|
49
|
+
* while the run did something else entirely. The owner names itself in `by`;
|
|
50
|
+
* neither the routes nor the tool has a `by` to pass, so this one function
|
|
51
|
+
* closes both.
|
|
52
|
+
*/
|
|
53
|
+
const ownerOf = (task) => task.creator === "http" || task.creator.startsWith("session:") ? null : task.creator;
|
|
10
54
|
export const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
11
55
|
? value
|
|
12
56
|
: null;
|
|
@@ -76,12 +120,6 @@ function parseLaunch(raw) {
|
|
|
76
120
|
}
|
|
77
121
|
launch.thinking = value.thinking;
|
|
78
122
|
}
|
|
79
|
-
if (value.capabilities !== undefined) {
|
|
80
|
-
if (value.capabilities !== "read" && value.capabilities !== "write") {
|
|
81
|
-
throw new Error("agent capabilities must be read or write");
|
|
82
|
-
}
|
|
83
|
-
launch.capabilities = value.capabilities;
|
|
84
|
-
}
|
|
85
123
|
return Object.keys(launch).length ? launch : undefined;
|
|
86
124
|
}
|
|
87
125
|
export class TaskDefinitions {
|
|
@@ -110,7 +148,7 @@ export class TaskDefinitions {
|
|
|
110
148
|
const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw);
|
|
111
149
|
const now = Date.now();
|
|
112
150
|
const task = {
|
|
113
|
-
id:
|
|
151
|
+
id: newId(),
|
|
114
152
|
kind,
|
|
115
153
|
name: draft.name,
|
|
116
154
|
description: draft.description ?? "",
|
|
@@ -133,8 +171,9 @@ export class TaskDefinitions {
|
|
|
133
171
|
this.changed();
|
|
134
172
|
return task;
|
|
135
173
|
}
|
|
136
|
-
async update(id, raw) {
|
|
174
|
+
async update(id, raw, by) {
|
|
137
175
|
const old = this.get(id);
|
|
176
|
+
this.assertOwner(old, by, "edited");
|
|
138
177
|
if (old.archived)
|
|
139
178
|
throw new Error("archived tasks cannot be edited");
|
|
140
179
|
const draft = await this.parseDraft(raw);
|
|
@@ -158,8 +197,9 @@ export class TaskDefinitions {
|
|
|
158
197
|
this.changed();
|
|
159
198
|
return task;
|
|
160
199
|
}
|
|
161
|
-
setEnabled(id, enabled) {
|
|
200
|
+
setEnabled(id, enabled, by) {
|
|
162
201
|
const task = this.get(id);
|
|
202
|
+
this.assertOwner(task, by, enabled ? "resumed" : "paused");
|
|
163
203
|
if (task.archived && enabled)
|
|
164
204
|
throw new Error("archived tasks cannot be resumed");
|
|
165
205
|
task.enabled = enabled;
|
|
@@ -169,8 +209,9 @@ export class TaskDefinitions {
|
|
|
169
209
|
this.changed();
|
|
170
210
|
return task;
|
|
171
211
|
}
|
|
172
|
-
archive(id) {
|
|
212
|
+
archive(id, by) {
|
|
173
213
|
const task = this.get(id);
|
|
214
|
+
this.assertOwner(task, by, "archived");
|
|
174
215
|
task.archived = true;
|
|
175
216
|
task.enabled = false;
|
|
176
217
|
task.nextRunAt = null;
|
|
@@ -200,7 +241,14 @@ export class TaskDefinitions {
|
|
|
200
241
|
}
|
|
201
242
|
async sessionExists(sessionId) {
|
|
202
243
|
return this.router.stateOf(sessionId) !== undefined ||
|
|
203
|
-
(await this.factory.
|
|
244
|
+
(await this.factory.find(sessionId)) !== undefined;
|
|
245
|
+
}
|
|
246
|
+
/** The guard `ownerOf` exists for, on the three ways a definition changes. */
|
|
247
|
+
assertOwner(task, by, what) {
|
|
248
|
+
const owner = ownerOf(task);
|
|
249
|
+
if (owner && by !== owner) {
|
|
250
|
+
throw new Error(`"${task.name}" is Pier's own ${owner} task: it is reconciled by Pier, not ${what}`);
|
|
251
|
+
}
|
|
204
252
|
}
|
|
205
253
|
async parseDraft(raw) {
|
|
206
254
|
const value = record(raw);
|
package/dist/tasks/execution.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// One queued run carried to a result: dispatched by action kind (a bash
|
|
2
|
+
// script, an agent session, another task), abortable while it goes, settled
|
|
3
|
+
// exactly once. What the two action kinds actually do lives in command.ts and
|
|
4
|
+
// agent.ts; this file owns only the lifecycle they share.
|
|
1
5
|
import { logger } from "../log.js";
|
|
2
6
|
import { AgentTaskRunner } from "./agent.js";
|
|
3
7
|
import { TaskCallbacks } from "./callbacks.js";
|
|
@@ -56,7 +60,11 @@ export class TaskExecution {
|
|
|
56
60
|
run.result = await this.executeAction(run, controller.signal);
|
|
57
61
|
run.state = "succeeded";
|
|
58
62
|
if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
|
|
59
|
-
|
|
63
|
+
// As the definition's own creator: a one-shot watch retiring itself is
|
|
64
|
+
// the task layer keeping its own promise, not a surface editing
|
|
65
|
+
// somebody's task, and the owner guard (definitions.ts) would
|
|
66
|
+
// otherwise fail the run that had just succeeded.
|
|
67
|
+
this.definitions.setEnabled(definition.id, false, definition.creator);
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
catch (error) {
|
package/dist/tasks/groups.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
// The fan-out join: members start detached, and the group is what turns their
|
|
2
|
+
// separate endings into one answer — when the join condition is met, which
|
|
3
|
+
// members are cancelled, and the single aggregated callback that goes back.
|
|
4
|
+
// Delivering it is the outbox's job; deciding it is this file's.
|
|
2
5
|
import { Router } from "../core/router.js";
|
|
3
6
|
import { logger } from "../log.js";
|
|
4
|
-
import { runResultText } from "./callbacks.js";
|
|
7
|
+
import { runRef, runResultText } from "./callbacks.js";
|
|
8
|
+
import { newId } from "./definitions.js";
|
|
5
9
|
import { Outbox } from "./outbox.js";
|
|
6
10
|
import { TaskStore } from "./store.js";
|
|
7
11
|
import { isTerminal } from "./types.js";
|
|
@@ -61,7 +65,7 @@ export class TaskGroups {
|
|
|
61
65
|
}
|
|
62
66
|
create(join, invokedBySessionId, callbackSessionId) {
|
|
63
67
|
const group = {
|
|
64
|
-
id:
|
|
68
|
+
id: newId(),
|
|
65
69
|
join,
|
|
66
70
|
invokedBySessionId,
|
|
67
71
|
callbackSessionId,
|
|
@@ -137,7 +141,7 @@ export class TaskGroups {
|
|
|
137
141
|
const sections = members.map((run) => {
|
|
138
142
|
const head = [
|
|
139
143
|
`- "${run.context.definition.name}" \u2014 state: ${run.state}`,
|
|
140
|
-
`
|
|
144
|
+
` ${runRef(run)}`,
|
|
141
145
|
];
|
|
142
146
|
const decision = this.host.openDecisionId(run.id);
|
|
143
147
|
if (decision)
|