@timqi/pier 0.1.14 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -22
- package/dist/agent/config.js +2 -2
- package/dist/agent/events.js +32 -3
- package/dist/agent/packages.js +6 -10
- package/dist/agent/pi.js +111 -68
- package/dist/agent/roles.js +103 -0
- package/dist/agent/types.js +78 -0
- package/dist/boards/boards.js +42 -32
- package/dist/channels/chains.js +0 -4
- package/dist/channels/commands.js +7 -12
- package/dist/channels/config.js +27 -1
- package/dist/channels/control.js +5 -16
- package/dist/channels/conversations.js +0 -5
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +16 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +61 -39
- package/dist/channels/lines.js +33 -1
- package/dist/channels/panel.js +17 -107
- package/dist/channels/routes.js +11 -21
- package/dist/channels/runtime.js +34 -25
- package/dist/channels/slack-outbound.js +6 -15
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack.js +63 -57
- package/dist/channels/types.js +1 -1
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +202 -0
- package/dist/core/identity.js +34 -11
- package/dist/core/inbound-file.js +4 -3
- package/dist/core/reply.js +96 -80
- package/dist/core/router.js +111 -118
- package/dist/core/types.js +36 -77
- package/dist/db.js +33 -2
- package/dist/main.js +46 -37
- package/dist/settings.js +25 -18
- package/dist/tasks/agent.js +11 -5
- package/dist/tasks/callbacks.js +55 -5
- package/dist/tasks/cli.js +22 -11
- package/dist/tasks/definitions.js +30 -17
- package/dist/tasks/execution.js +2 -2
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +70 -0
- package/dist/tasks/operations.js +76 -39
- package/dist/tasks/outbox.js +19 -6
- package/dist/tasks/routes.js +19 -249
- package/dist/tasks/service.js +145 -34
- package/dist/tasks/store.js +119 -56
- package/dist/tasks/types.js +5 -0
- package/dist/tools.js +2 -2
- package/dist/web/auth.js +2 -2
- package/dist/web/config-sync.js +4 -5
- package/dist/web/config.js +9 -2
- package/dist/web/explorer.js +2 -2
- package/dist/web/fs.js +16 -1
- package/dist/web/instance.js +8 -5
- package/dist/web/packages.js +1 -1
- package/dist/web/passkeys.js +3 -3
- package/dist/web/providers.js +3 -3
- package/dist/web/public/assets/code-BZwvPani.js +2 -0
- package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
- package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
- package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
- package/dist/web/public/index.html +40 -109
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +0 -2
- package/dist/web/push.js +1 -1
- package/dist/web/server.js +91 -116
- package/dist/web/session-state.js +5 -40
- package/docs/deploy.md +5 -3
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +14 -4
- package/skills/pier-help/SKILL.md +57 -23
- package/skills/pier-tasks/SKILL.md +58 -18
- package/dist/channels/handoff.js +0 -94
- package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
- package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
- package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
- package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
- package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js +0 -85
- package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
- package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
- package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
- package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
- package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
- package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@ something is worth showing.
|
|
|
9
9
|
One instance, one account, your own machine. The agent runs shell commands in
|
|
10
10
|
directories you name — meant for a machine you own, not a shared host.
|
|
11
11
|
|
|
12
|
-
**Status: pre-release.**
|
|
13
|
-
|
|
14
|
-
reachable.
|
|
12
|
+
**Status: pre-release.** Releases may still change behaviour and the
|
|
13
|
+
database schema between versions; schema upgrades are one-way. Read
|
|
14
|
+
`docs/deploy.md` before putting it anywhere reachable.
|
|
15
15
|
|
|
16
16
|
## Requirements
|
|
17
17
|
|
|
@@ -30,15 +30,35 @@ pier serve
|
|
|
30
30
|
|
|
31
31
|
It listens on `127.0.0.1:3141` (`PORT`, `HOST`) and keeps everything under
|
|
32
32
|
`~/.pier` (`PIER_HOME`): one SQLite database, the boards it serves, the
|
|
33
|
-
`master.key` that seals stored credentials,
|
|
34
|
-
|
|
35
|
-
otherwise).
|
|
33
|
+
`master.key` that seals stored credentials, the conversation's memory
|
|
34
|
+
(`~/.pier/home`), and the Pi runtime with its session transcripts
|
|
35
|
+
(`~/.pier/pi`, unless `PI_CODING_AGENT_DIR` says otherwise).
|
|
36
36
|
|
|
37
37
|
**The first start generates a password and prints it once.** Lost it?
|
|
38
38
|
`sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart. Open
|
|
39
|
-
`http://localhost:3141`, sign in; **Console → Settings**
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
`http://localhost:3141`, sign in; **Console → Settings** has Models, Agent,
|
|
40
|
+
Channels, Vault, Tasks, Boards, Instance (public URL, accent, notifications,
|
|
41
|
+
reload) and Security (master key, password, passkeys, signed-in devices).
|
|
42
|
+
|
|
43
|
+
## How the conversation works
|
|
44
|
+
|
|
45
|
+
The web chat is one conversation, Pier. Its current session answers,
|
|
46
|
+
remembers and dispatches; it runs in `~/.pier/home`, which holds
|
|
47
|
+
memory only: `MEMORY.md` (one-line facts) and daily notes in
|
|
48
|
+
`memory/YYYY-MM-DD.md`.
|
|
49
|
+
|
|
50
|
+
- Real work is delegated with `pier task run`: a worker for a small task, a
|
|
51
|
+
lead for a feature, which designs with you or builds with its own workers.
|
|
52
|
+
Each is its own session; its result comes back to Pier as a callback.
|
|
53
|
+
- After an idle hour, or past 60K tokens, the next message starts a fresh
|
|
54
|
+
session, seeded with the memory, the open items, recent runs and the last
|
|
55
|
+
exchanges. The old transcripts stay readable.
|
|
56
|
+
- `/status` lists what is open, `/new` starts a fresh session now, `/stop` stops
|
|
57
|
+
the reply in progress, `/skills` lists the skills. `%` works in place of
|
|
58
|
+
`/` (`%status`), because Slack's client eats an unregistered `/`.
|
|
59
|
+
- One IM DM, on Lark or Slack, can be the conversation: Console → Settings →
|
|
60
|
+
Channels, the DM's row, "This DM is the conversation". Pier then
|
|
61
|
+
answers there, from the phone; other chats keep one session per thread.
|
|
42
62
|
|
|
43
63
|
## Configure Pi
|
|
44
64
|
|
|
@@ -58,14 +78,15 @@ Console → Settings:
|
|
|
58
78
|
- **Models** — endpoints, API-key/OAuth login, pinned models. Credentials are
|
|
59
79
|
sealed in Pier's SQLite database, never written back to `models.json`.
|
|
60
80
|
**Test** sends one real request and shows the body as sent and the reply.
|
|
61
|
-
- **Agent** — edits `SYSTEM.md`, `AGENTS.md
|
|
62
|
-
|
|
63
|
-
`.pi/skills` and `.pi/extensions` listed).
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
- **Agent** — edits `SYSTEM.md`, `AGENTS.md` and the `models.json`
|
|
82
|
+
structure, globally or per project scope (with that project's
|
|
83
|
+
`.pi/skills` and `.pi/extensions` listed); `settings.json` is Pier's and
|
|
84
|
+
shown read-only. Changes apply when a session next opens; saving recycles
|
|
85
|
+
idle sessions; **Settings → Instance → Reload** does the same for files
|
|
86
|
+
changed elsewhere. Also here: packages (Pi's registry, **Add package**),
|
|
87
|
+
Pier's own skills as the built-in `pier` package (`pier web search|fetch`
|
|
88
|
+
reaches the web through the provider's hosted tools — Anthropic or OpenAI,
|
|
89
|
+
no key of your own), **Browse files**, and the managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
|
|
69
90
|
[ubix](https://github.com/timqi/ubix) block), installed into
|
|
70
91
|
`~/.pier/tools/bin`, first on every session's PATH beside the `pier` shim
|
|
71
92
|
Pier writes there at start.
|
|
@@ -79,7 +100,7 @@ store and renamed `auth.json.imported`; literal keys in `models.json` likewise
|
|
|
79
100
|
(`models.json.imported`). The editor rejects plaintext keys or header values.
|
|
80
101
|
Pi's provider environment variables are inherited from the Pier process; a
|
|
81
102
|
systemd service does not inherit your shell, so put non-secret settings in a
|
|
82
|
-
unit override and API keys in
|
|
103
|
+
unit override and API keys in Settings → Models.
|
|
83
104
|
|
|
84
105
|
## Run it as a service
|
|
85
106
|
|
|
@@ -118,8 +139,9 @@ npm test # vitest
|
|
|
118
139
|
|
|
119
140
|
## Releases
|
|
120
141
|
|
|
121
|
-
Pier asks `registry.npmjs.org` at boot and every 30 minutes; the
|
|
122
|
-
|
|
142
|
+
Pier asks `registry.npmjs.org` at boot and every 30 minutes; the version in
|
|
143
|
+
the Settings header turns into a badge when a release is out (`Pier <version>
|
|
144
|
+
is out`) and opens a panel: source link,
|
|
123
145
|
**Update now**, **Update automatically** (idle instance only). Both drain first
|
|
124
146
|
and hand the install to the updater unit; off systemd the panel says `pier
|
|
125
147
|
update`. The updater writes `~/.pier/db/backups/pier.db.release-<version>.bak`
|
|
@@ -128,10 +150,10 @@ update`. The updater writes `~/.pier/db/backups/pier.db.release-<version>.bak`
|
|
|
128
150
|
|
|
129
151
|
`main` is the only development line. `just release [patch|minor|major]` checks,
|
|
130
152
|
tags and pushes; the tag builds and publishes to npm and a GitHub Release. The
|
|
131
|
-
|
|
153
|
+
version shown is `package.json`'s.
|
|
132
154
|
|
|
133
155
|
## License
|
|
134
156
|
|
|
135
157
|
[AGPL-3.0-only](LICENSE). Run it, change it, deploy it. If you offer a modified
|
|
136
158
|
Pier to other people over a network, they are entitled to your source — the
|
|
137
|
-
version in the
|
|
159
|
+
version in the Settings header links to this repository for exactly that reason.
|
package/dist/agent/config.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { isThinkingLevel } from "../core/types.js";
|
|
8
|
+
import { isProviderApi, SNAPSHOT_FILES, validateEndpoint, validateProviderSetup } from "./types.js";
|
|
8
9
|
import { pierPath } from "../paths.js";
|
|
9
10
|
import { mergeSnapshotProviders, normalizeAgentSnapshot, snapshotProviders } from "./config-sync.js";
|
|
10
11
|
const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
|
|
@@ -13,7 +14,6 @@ const PROJECT_FILES = ["AGENTS.md"];
|
|
|
13
14
|
// go through writeDefaults, and are the only fields a snapshot carries. The
|
|
14
15
|
// Console shows the file and never edits it; the rest of it is machine-local.
|
|
15
16
|
const READONLY_FILES = ["settings.json"];
|
|
16
|
-
const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
|
|
17
17
|
/** What a first boot writes (docs/deploy.md says why each key): the list the
|
|
18
18
|
* Console installs into, Pi's attribution headers off — a server is not a
|
|
19
19
|
* person to survey — and a retry budget of minutes, because an unattended
|
package/dist/agent/events.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pure Pi-event → SessionEventPayload translation. Structurally typed: no Pi
|
|
2
2
|
// imports, so it is unit-testable without Pi and Pi types never leak past the
|
|
3
3
|
// seam. The golden table in events.test.ts is the mapping's spec.
|
|
4
|
-
import { isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
|
|
4
|
+
import { isChatCommand, isThinkingLevel, MAX_STEP_OUTPUT } from "../core/types.js";
|
|
5
5
|
/** An assistant message that calls a tool is work in progress, not a reply. */
|
|
6
6
|
export const hasToolCalls = (message) => Array.isArray(message?.content) && message.content.some((part) => part.type === "toolCall");
|
|
7
7
|
export function textOf(content) {
|
|
@@ -20,14 +20,36 @@ function systemOrigin(message) {
|
|
|
20
20
|
const value = message.details;
|
|
21
21
|
if (!value || typeof value !== "object")
|
|
22
22
|
return null;
|
|
23
|
-
const { source: raw, ...origin } = value;
|
|
23
|
+
const { source: raw, cwd, ...origin } = value;
|
|
24
|
+
if (origin.kind === "session-seed") {
|
|
25
|
+
const { reason, previousSessionId } = origin;
|
|
26
|
+
return (reason === "first" || reason === "idle" || reason === "lost" || reason === "full" || reason === "new") &&
|
|
27
|
+
(previousSessionId === null || typeof previousSessionId === "string")
|
|
28
|
+
? { kind: "session-seed", reason, previousSessionId }
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
if (origin.kind === "chat-command") {
|
|
32
|
+
const { command, sessions } = origin;
|
|
33
|
+
if (!isChatCommand(command))
|
|
34
|
+
return null;
|
|
35
|
+
// A malformed map costs the card its links, not the card.
|
|
36
|
+
const links = sessions && typeof sessions === "object" && !Array.isArray(sessions) && Object.values(sessions).every((v) => typeof v === "string")
|
|
37
|
+
? { sessions: sessions }
|
|
38
|
+
: {};
|
|
39
|
+
return { kind: "chat-command", command, ...links };
|
|
40
|
+
}
|
|
24
41
|
if (typeof origin.taskId !== "string" ||
|
|
25
42
|
typeof origin.runId !== "string" ||
|
|
26
43
|
(origin.sourceSessionId !== null && typeof origin.sourceSessionId !== "string"))
|
|
27
44
|
return null;
|
|
28
45
|
// A half-valid `source` drawn by the card is an `undefined` in a chip.
|
|
29
46
|
const source = inputSource(raw);
|
|
30
|
-
|
|
47
|
+
// A callback's, and relative paths resolve against it, so only an absolute one is kept.
|
|
48
|
+
const shape = {
|
|
49
|
+
...origin,
|
|
50
|
+
...(source ? { source } : {}),
|
|
51
|
+
...(origin.kind === "task-callback" && typeof cwd === "string" && cwd.startsWith("/") ? { cwd } : {}),
|
|
52
|
+
};
|
|
31
53
|
if (origin.kind === "task-delegation" || origin.kind === "task-callback") {
|
|
32
54
|
return shape;
|
|
33
55
|
}
|
|
@@ -168,6 +190,13 @@ export function toChatTurns(messages) {
|
|
|
168
190
|
}
|
|
169
191
|
}
|
|
170
192
|
const text = textOf(m.content);
|
|
193
|
+
// Only the attempt that ended the turn: one Pi retried past is no failure.
|
|
194
|
+
if (m.role === "assistant" && m.stopReason === "error" && messages[i + 1]?.role !== "assistant") {
|
|
195
|
+
flush("assistant", hasTools ? "" : text, turnMetaAt(messages, i), undefined, m.timestamp);
|
|
196
|
+
turns[turns.length - 1].error = m.errorMessage || "unknown agent error";
|
|
197
|
+
candidate = undefined;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
171
200
|
// Tool-bearing messages are intermediate work, even when they include text.
|
|
172
201
|
if (!text || hasTools)
|
|
173
202
|
continue;
|
package/dist/agent/packages.js
CHANGED
|
@@ -5,17 +5,16 @@
|
|
|
5
5
|
import { existsSync, promises as fs } from "node:fs";
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { DefaultPackageManager, loadSkills, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { PackageError, } from "
|
|
8
|
+
import { PackageError, } from "./types.js";
|
|
9
9
|
import { logger } from "../log.js";
|
|
10
10
|
import { defaultAgentDir } from "./config.js";
|
|
11
11
|
const log = logger("packages");
|
|
12
12
|
/** Set by how long a released fix may sit unnoticed, like update.ts. */
|
|
13
13
|
const CHECK_EVERY_MS = 24 * 60 * 60_000;
|
|
14
14
|
const KINDS = { extensions: "extension", skills: "skill" };
|
|
15
|
-
/** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook):
|
|
16
|
-
*
|
|
15
|
+
/** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): the rtk tool's, so it
|
|
16
|
+
* is left out of `local` — the Console switches it as that tool, and a settings.json pattern would fight it. */
|
|
17
17
|
const RTK_FILE = join("extensions", "rtk.ts");
|
|
18
|
-
const RTK_STATE = "installed by the rtk tool";
|
|
19
18
|
export const kindOf = (source) => source.startsWith("npm:") ? "npm" : /^(git:|(https?|ssh|git):\/\/)/.test(source) ? "git" : "path";
|
|
20
19
|
/** A trailing `@ref` on an npm or git source; the leading `@` of a scope is not one. */
|
|
21
20
|
export const pinnedRef = (source) => {
|
|
@@ -120,13 +119,12 @@ export class PiPackageStore {
|
|
|
120
119
|
row("local", "local", "global", this.agentDir);
|
|
121
120
|
for (const key of Object.keys(KINDS)) {
|
|
122
121
|
for (const r of resolved[key]) {
|
|
122
|
+
if (r.path === rtkPath)
|
|
123
|
+
continue;
|
|
123
124
|
const own = r.metadata.origin === "top-level";
|
|
124
125
|
const scope = r.metadata.scope === "project" ? "project" : "global";
|
|
125
|
-
const resource = resourceRow(KINDS[key], r);
|
|
126
|
-
if (r.path === rtkPath)
|
|
127
|
-
Object.assign(resource, { state: RTK_STATE, locked: true });
|
|
128
126
|
row(own ? "local" : r.metadata.source, own ? "local" : kindOf(r.metadata.source), scope, r.metadata.baseDir ?? null)
|
|
129
|
-
.resources.push(
|
|
127
|
+
.resources.push(resourceRow(KINDS[key], r));
|
|
130
128
|
}
|
|
131
129
|
}
|
|
132
130
|
// Configured but unresolved (not installed, or empty): still a row.
|
|
@@ -277,8 +275,6 @@ export class PiPackageStore {
|
|
|
277
275
|
async setEnabled(change) {
|
|
278
276
|
const { source, kind, path, enabled, cwd } = change;
|
|
279
277
|
const { pkg, resource } = await this.#find(change);
|
|
280
|
-
if (resource.locked)
|
|
281
|
-
throw new PackageError("refused", `${resource.name} is ${resource.state} — its switch is under Tools`);
|
|
282
278
|
if (pkg.kind === "pier") {
|
|
283
279
|
this.pier.settings.setSkillsOff(withName(this.pier.settings.get().skillsOff, resource.name, !enabled));
|
|
284
280
|
}
|
package/dist/agent/pi.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// Pi SDK. No Pi type may appear in an exported signature.
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
|
-
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, sessionEntryToContextMessages, } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { SESSION_TITLE_MAX } from "../core/types.js";
|
|
8
8
|
import { logger } from "../log.js";
|
|
9
|
+
import { pierPath } from "../paths.js";
|
|
10
|
+
import { DISPATCHER, LEAD } from "./roles.js";
|
|
9
11
|
import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
10
12
|
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
11
13
|
import { IndexedListing } from "./listing.js";
|
|
@@ -99,7 +101,7 @@ These rules govern conversational replies. A human reads them on a phone-sized s
|
|
|
99
101
|
- Before touching files: list and search first. Never guess a path or a line number.
|
|
100
102
|
- Read before you edit. Match the surrounding code's style, naming, and comment density.
|
|
101
103
|
- Do exactly what was asked. No unrequested refactors, no extra files, no README updates.
|
|
102
|
-
- Each bash call is a fresh shell in the working directory; chain what must share state.
|
|
104
|
+
- Each bash call is a fresh shell in the working directory; chain what must share state. Don't prefix commands with \`cd\` to that same directory — use relative paths; \`cd\` only to go elsewhere.
|
|
103
105
|
- Destructive or irreversible actions on things you didn't create — deleting user files, force push, migrations, deploys, service restarts: ask first; unattended, don't do them and report what you would have done.
|
|
104
106
|
- Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
|
|
105
107
|
export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
|
|
@@ -111,25 +113,37 @@ const bashTimeoutDefault = (pi) => {
|
|
|
111
113
|
}
|
|
112
114
|
});
|
|
113
115
|
};
|
|
116
|
+
/** The transcript's current branch in order, compacted entries included. */
|
|
117
|
+
const branchMessages = (sessionManager) => sessionManager.getBranch().flatMap((entry) => sessionEntryToContextMessages(entry));
|
|
118
|
+
/** Where a session compacts — a main session
|
|
119
|
+
* and a session a run launched from a session made (a lead or a worker, which
|
|
120
|
+
* never rotate); above 200K input, 1M-context models price higher. */
|
|
121
|
+
const MAIN_COMPACTION_CAP = 100_000;
|
|
122
|
+
const CHILD_COMPACTION_CAP = 150_000;
|
|
114
123
|
export class PiSession {
|
|
115
124
|
pi;
|
|
116
125
|
pinned;
|
|
117
126
|
wrote;
|
|
118
127
|
retention;
|
|
119
128
|
suggestTitle;
|
|
129
|
+
cap;
|
|
120
130
|
constructor(pi,
|
|
121
131
|
/** Read per call — the menu can change while we run. */
|
|
122
132
|
pinned = () => [],
|
|
123
|
-
/** Drops the factory's retained listing: a
|
|
124
|
-
* window it covers, and every surface would keep the old
|
|
133
|
+
/** Drops the factory's retained listing: a title lands in exactly the
|
|
134
|
+
* window it covers, and every surface would keep the old one. */
|
|
125
135
|
wrote = () => { }, retention = { value: "long" },
|
|
126
136
|
/** Read per turn: switching auto-titling on takes effect without a restart. */
|
|
127
|
-
suggestTitle = () => undefined
|
|
137
|
+
suggestTitle = () => undefined,
|
|
138
|
+
/** Auto-compaction triggers once the context passes this many tokens. */
|
|
139
|
+
cap) {
|
|
128
140
|
this.pi = pi;
|
|
129
141
|
this.pinned = pinned;
|
|
130
142
|
this.wrote = wrote;
|
|
131
143
|
this.retention = retention;
|
|
132
144
|
this.suggestTitle = suggestTitle;
|
|
145
|
+
this.cap = cap;
|
|
146
|
+
this.applyCap();
|
|
133
147
|
}
|
|
134
148
|
/** A turn started after Pi's dispose runs for real and lands nowhere — no
|
|
135
149
|
* transcript, no event, a promise that resolves. Refusing makes it a failure (§5). */
|
|
@@ -153,7 +167,7 @@ export class PiSession {
|
|
|
153
167
|
}
|
|
154
168
|
get contextUsage() {
|
|
155
169
|
const u = this.pi.getContextUsage();
|
|
156
|
-
return u ? { tokens: u.tokens, contextWindow: u.contextWindow } : undefined;
|
|
170
|
+
return u ? { tokens: u.tokens, contextWindow: u.contextWindow, compactAt: u.contextWindow - this.reserve(u.contextWindow) } : undefined;
|
|
157
171
|
}
|
|
158
172
|
async setModel(ref) {
|
|
159
173
|
const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
|
|
@@ -163,6 +177,8 @@ export class PiSession {
|
|
|
163
177
|
throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
|
|
164
178
|
}
|
|
165
179
|
await this.pi.setModel(m);
|
|
180
|
+
// The reserve is per context window, so a cap outlives a model switch only if recomputed.
|
|
181
|
+
this.applyCap();
|
|
166
182
|
}
|
|
167
183
|
async availableModels() {
|
|
168
184
|
const available = await this.pi.modelRuntime.getAvailable();
|
|
@@ -183,6 +199,27 @@ export class PiSession {
|
|
|
183
199
|
setCacheRetention(retention) {
|
|
184
200
|
this.retention.value = retention;
|
|
185
201
|
}
|
|
202
|
+
instanceReserve;
|
|
203
|
+
/** Pi compacts past `contextWindow − reserveTokens`; this session's settings
|
|
204
|
+
* manager is its own, so the override reaches no other session. Never later
|
|
205
|
+
* than the instance's own reserve. */
|
|
206
|
+
applyCap() {
|
|
207
|
+
const window = this.pi.model?.contextWindow;
|
|
208
|
+
if (this.cap === undefined || !window)
|
|
209
|
+
return;
|
|
210
|
+
this.pi.settingsManager.applyOverrides({ compaction: { reserveTokens: this.reserve(window) } });
|
|
211
|
+
}
|
|
212
|
+
/** The instance's reserve is read once under a cap, before the first override replaces it. */
|
|
213
|
+
reserve(window) {
|
|
214
|
+
const settings = this.pi.settingsManager;
|
|
215
|
+
if (this.cap === undefined)
|
|
216
|
+
return settings.getCompactionSettings().reserveTokens;
|
|
217
|
+
this.instanceReserve ??= settings.getCompactionSettings().reserveTokens;
|
|
218
|
+
return Math.max(window - this.cap, this.instanceReserve);
|
|
219
|
+
}
|
|
220
|
+
skills() {
|
|
221
|
+
return this.pi.resourceLoader.getSkills().skills.map(({ name, description }) => ({ name, description }));
|
|
222
|
+
}
|
|
186
223
|
async pendingQueue() {
|
|
187
224
|
return {
|
|
188
225
|
steering: [...this.pi.getSteeringMessages()],
|
|
@@ -209,12 +246,11 @@ export class PiSession {
|
|
|
209
246
|
}
|
|
210
247
|
async history() {
|
|
211
248
|
this.live();
|
|
212
|
-
return toChatTurns(this.pi.
|
|
249
|
+
return toChatTurns(branchMessages(this.pi.sessionManager));
|
|
213
250
|
}
|
|
214
251
|
async rewindToUserTurn(index) {
|
|
215
252
|
const total = (await this.history()).filter((t) => t.role === "user").length;
|
|
216
|
-
//
|
|
217
|
-
// shows, so only end-relative indices line up.
|
|
253
|
+
// End-relative: a user entry toChatTurns would not count cannot shift the target.
|
|
218
254
|
const back = total - index;
|
|
219
255
|
const users = this.pi.sessionManager
|
|
220
256
|
.getBranch()
|
|
@@ -227,49 +263,41 @@ export class PiSession {
|
|
|
227
263
|
if (cancelled)
|
|
228
264
|
throw new Error("rewind cancelled");
|
|
229
265
|
}
|
|
230
|
-
/** Pi keeps no lock of its own: a second `compact()` summarizes a transcript
|
|
231
|
-
* being replaced under it, and two POSTs a millisecond apart both pass the
|
|
232
|
-
* route's idle check. */
|
|
233
|
-
compacting = null;
|
|
234
|
-
async compact() {
|
|
235
|
-
this.live();
|
|
236
|
-
if (this.compacting)
|
|
237
|
-
throw new Error(`session ${this.pi.sessionId} is already compacting`);
|
|
238
|
-
// Recorded in the same tick, no await between: that is what makes the check a gate.
|
|
239
|
-
const running = this.pi.compact().then(() => undefined);
|
|
240
|
-
this.compacting = running;
|
|
241
|
-
try {
|
|
242
|
-
await running;
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
this.compacting = null;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
/** An append: Pi's reader takes the latest `session_info`. Never refused for
|
|
249
|
-
* being busy. TODO: renaming a cold session costs a whole resume for one
|
|
250
|
-
* appended line; revisit when Pi offers a lightweight append. */
|
|
251
|
-
async rename(name) {
|
|
252
|
-
this.live();
|
|
253
|
-
this.pi.sessionManager.appendSessionInfo(name);
|
|
254
|
-
this.wrote();
|
|
255
|
-
}
|
|
256
|
-
/** A dispatch landing mid-compaction waits for the summary instead of
|
|
257
|
-
* starting a turn over it. Not Pi's follow-up queue: that is drained only by
|
|
258
|
-
* the *next* turn, so a message parked there while idle would sit unsent. */
|
|
259
|
-
async whenCompacted() {
|
|
260
|
-
while (this.compacting)
|
|
261
|
-
await this.compacting.catch(() => undefined);
|
|
262
|
-
}
|
|
263
266
|
// Async, so a refusal is a rejected promise the seam lets callers `.catch()`.
|
|
264
267
|
async prompt(text) {
|
|
265
|
-
this.live();
|
|
266
|
-
await this.whenCompacted();
|
|
267
|
-
// The wait above is long enough for a dispose to land.
|
|
268
268
|
this.live();
|
|
269
269
|
// A turn may have started since the caller read the state. Bare, Pi throws
|
|
270
270
|
// "already processing" and the message is gone (§5); queued, it is the
|
|
271
271
|
// same "delivered when idle" core/queue.ts picks for a mid-turn message.
|
|
272
|
-
|
|
272
|
+
let refused = false;
|
|
273
|
+
try {
|
|
274
|
+
await this.pi.prompt(text, { streamingBehavior: "followUp", preflightResult: (ok) => { refused = !ok; } });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (refused)
|
|
278
|
+
this.recordRefusal(text, error);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Pi refuses before writing anything (no model, no key), so without this the
|
|
283
|
+
* message and its reason would exist only as a live event (§5). Recorded the
|
|
284
|
+
* way a provider failure is — an errored reply the model's context drops. */
|
|
285
|
+
recordRefusal(text, error) {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
const manager = this.pi.sessionManager;
|
|
288
|
+
manager.appendMessage({ role: "user", content: [{ type: "text", text }], timestamp: now });
|
|
289
|
+
manager.appendMessage({
|
|
290
|
+
role: "assistant",
|
|
291
|
+
content: [],
|
|
292
|
+
api: this.pi.model?.api ?? "",
|
|
293
|
+
provider: this.pi.model?.provider ?? "",
|
|
294
|
+
model: this.pi.model?.id ?? "",
|
|
295
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
296
|
+
stopReason: "error",
|
|
297
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
298
|
+
timestamp: now,
|
|
299
|
+
});
|
|
300
|
+
this.pi.agent.state.messages = manager.buildSessionProjection().messages;
|
|
273
301
|
}
|
|
274
302
|
async steer(text) {
|
|
275
303
|
this.live();
|
|
@@ -281,15 +309,13 @@ export class PiSession {
|
|
|
281
309
|
}
|
|
282
310
|
async systemInput(text, origin, mode) {
|
|
283
311
|
this.live();
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
this.live();
|
|
287
|
-
// Read after the wait: a running turn means the call below queues.
|
|
288
|
-
const queued = this.pi.isStreaming;
|
|
312
|
+
// A running turn means the call below queues.
|
|
313
|
+
const queued = this.pi.isStreaming && mode !== "append";
|
|
289
314
|
if (queued)
|
|
290
315
|
this.queuedInputs.push(origin);
|
|
291
316
|
try {
|
|
292
|
-
|
|
317
|
+
// Without a turn Pi appends it now, or after a running turn's tool results.
|
|
318
|
+
return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: mode !== "append", deliverAs: mode === "prompt" || mode === "append" ? undefined : mode });
|
|
293
319
|
}
|
|
294
320
|
catch (error) {
|
|
295
321
|
// Refused: nothing is in flight. The entry may be gone already.
|
|
@@ -338,8 +364,7 @@ export class PiSession {
|
|
|
338
364
|
if (!first.trim())
|
|
339
365
|
return;
|
|
340
366
|
void suggest(first, reply).then((title) => {
|
|
341
|
-
|
|
342
|
-
if (this.disposed || this.pi.sessionManager.getSessionName())
|
|
367
|
+
if (this.disposed)
|
|
343
368
|
return;
|
|
344
369
|
this.pi.sessionManager.appendSessionInfo(title);
|
|
345
370
|
this.wrote();
|
|
@@ -371,6 +396,7 @@ export class PiAgentFactory {
|
|
|
371
396
|
pier;
|
|
372
397
|
titleModel;
|
|
373
398
|
listings;
|
|
399
|
+
roleOf;
|
|
374
400
|
constructor(
|
|
375
401
|
/** Read per session open, so a Console change reaches the next session
|
|
376
402
|
* without a restart; appended as a context file so the user's own
|
|
@@ -384,7 +410,9 @@ export class PiAgentFactory {
|
|
|
384
410
|
/** The built-in `pier` package's one switch list: Pier's own skills switched off. */
|
|
385
411
|
pier = () => ({ skillsOff: [] }), titleModel = () => undefined,
|
|
386
412
|
/** Injected so a test needs no session directory or database. */
|
|
387
|
-
listings = new IndexedListing()
|
|
413
|
+
listings = new IndexedListing(),
|
|
414
|
+
/** A reopened session's role, which only its runs record (tasks/). */
|
|
415
|
+
roleOf = () => undefined) {
|
|
388
416
|
this.instructions = instructions;
|
|
389
417
|
this.skillPaths = skillPaths;
|
|
390
418
|
this.credentials = credentials;
|
|
@@ -393,6 +421,7 @@ export class PiAgentFactory {
|
|
|
393
421
|
this.pier = pier;
|
|
394
422
|
this.titleModel = titleModel;
|
|
395
423
|
this.listings = listings;
|
|
424
|
+
this.roleOf = roleOf;
|
|
396
425
|
}
|
|
397
426
|
/** Catalogs are global, not per session. */
|
|
398
427
|
catalog;
|
|
@@ -583,8 +612,9 @@ export class PiAgentFactory {
|
|
|
583
612
|
const modelRegistry = new ModelRegistry(await this.refreshedRuntime());
|
|
584
613
|
return { modelRegistry, model: active && modelRegistry.find(active.provider, active.id) };
|
|
585
614
|
}
|
|
586
|
-
async resourceLoader(cwd) {
|
|
587
|
-
|
|
615
|
+
async resourceLoader(cwd, role, dispatcher) {
|
|
616
|
+
// A worker never delegates (tasks/operations.ts refuses it), so it is not taught how.
|
|
617
|
+
const skillsOff = role === "worker" ? [...this.pier().skillsOff, "pier-tasks"] : this.pier().skillsOff;
|
|
588
618
|
const loader = new DefaultResourceLoader({
|
|
589
619
|
cwd,
|
|
590
620
|
agentDir: defaultAgentDir(),
|
|
@@ -601,19 +631,27 @@ export class PiAgentFactory {
|
|
|
601
631
|
agentsFilesOverride: (current) => {
|
|
602
632
|
const content = this.instructions();
|
|
603
633
|
return {
|
|
604
|
-
agentsFiles:
|
|
605
|
-
|
|
606
|
-
:
|
|
634
|
+
agentsFiles: [
|
|
635
|
+
...current.agentsFiles,
|
|
636
|
+
...(content ? [{ path: "<pier>/AGENTS.md", content }] : []),
|
|
637
|
+
...(dispatcher ? [{ path: "<pier>/dispatcher.md", content: DISPATCHER }] : []),
|
|
638
|
+
...(role === "lead" ? [{ path: "<pier>/lead.md", content: LEAD }] : []),
|
|
639
|
+
],
|
|
607
640
|
};
|
|
608
641
|
},
|
|
609
642
|
});
|
|
610
643
|
await loader.reload();
|
|
611
644
|
return loader;
|
|
612
645
|
}
|
|
613
|
-
open(
|
|
614
|
-
return this.providerConfig.withWrite(() => this.openSnapshot(
|
|
646
|
+
open(sessionManager, opts) {
|
|
647
|
+
return this.providerConfig.withWrite(() => this.openSnapshot(sessionManager, opts));
|
|
615
648
|
}
|
|
616
|
-
async openSnapshot(
|
|
649
|
+
async openSnapshot(sessionManager, opts) {
|
|
650
|
+
const { cwd, role } = opts;
|
|
651
|
+
// The home is where the continuous conversation's sessions run: only they
|
|
652
|
+
// dispatch, and they compact at main's cap.
|
|
653
|
+
const main = realPath(cwd) === realPath(pierPath("home"));
|
|
654
|
+
const cap = main ? MAIN_COMPACTION_CAP : role ? CHILD_COMPACTION_CAP : undefined;
|
|
617
655
|
// A locked store is a refusal with a reason here, not "provider not
|
|
618
656
|
// configured" later. Before appendSessionInfo, so nothing is written.
|
|
619
657
|
await this.credentials?.assertUnlocked();
|
|
@@ -630,7 +668,7 @@ export class PiAgentFactory {
|
|
|
630
668
|
cwd,
|
|
631
669
|
sessionManager,
|
|
632
670
|
modelRuntime: runtime,
|
|
633
|
-
resourceLoader: await this.resourceLoader(cwd),
|
|
671
|
+
resourceLoader: await this.resourceLoader(cwd, role, main),
|
|
634
672
|
});
|
|
635
673
|
const live = created.session;
|
|
636
674
|
// Pi defaults to one follow-up per turn boundary, so N queued messages cost
|
|
@@ -642,7 +680,7 @@ export class PiAgentFactory {
|
|
|
642
680
|
}, retention, () => {
|
|
643
681
|
const model = this.titleModel();
|
|
644
682
|
return model && ((first, reply) => this.suggestTitle(model, first, reply));
|
|
645
|
-
});
|
|
683
|
+
}, cap);
|
|
646
684
|
if (opts.model)
|
|
647
685
|
await session.setModel(opts.model);
|
|
648
686
|
if (opts.thinking)
|
|
@@ -654,13 +692,14 @@ export class PiAgentFactory {
|
|
|
654
692
|
this.listing = undefined;
|
|
655
693
|
// Resolved before Pi records it, so every later listing names it the same way.
|
|
656
694
|
const cwd = realPath(opts.cwd);
|
|
657
|
-
return this.open(
|
|
695
|
+
return this.open(SessionManager.create(cwd), { ...opts, cwd });
|
|
658
696
|
}
|
|
659
697
|
async resume(sessionId) {
|
|
698
|
+
const role = this.roleOf(sessionId);
|
|
660
699
|
const known = this.located.get(sessionId);
|
|
661
700
|
if (known) {
|
|
662
701
|
try {
|
|
663
|
-
return await this.open(
|
|
702
|
+
return await this.open(SessionManager.open(known.path), { cwd: known.cwd, role });
|
|
664
703
|
}
|
|
665
704
|
catch (err) {
|
|
666
705
|
log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
|
|
@@ -670,7 +709,7 @@ export class PiAgentFactory {
|
|
|
670
709
|
const info = await this.locate(sessionId);
|
|
671
710
|
if (!info)
|
|
672
711
|
throw new Error(`unknown session: ${sessionId}`);
|
|
673
|
-
return this.open(info.cwd || process.cwd(),
|
|
712
|
+
return this.open(SessionManager.open(info.path), { cwd: info.cwd || process.cwd(), role });
|
|
674
713
|
}
|
|
675
714
|
/** The one place "no such session" is decided. A retained listing is not
|
|
676
715
|
* evidence a session is gone, and callers read a miss as permission to start
|
|
@@ -681,6 +720,10 @@ export class PiAgentFactory {
|
|
|
681
720
|
return find(await this.listed()) ??
|
|
682
721
|
(reused && this.listing === reused ? find(await this.listed(true)) : undefined);
|
|
683
722
|
}
|
|
723
|
+
async readHistory(sessionId) {
|
|
724
|
+
const info = await this.locate(sessionId);
|
|
725
|
+
return info && toChatTurns(branchMessages(SessionManager.open(info.path)));
|
|
726
|
+
}
|
|
684
727
|
async find(sessionId) {
|
|
685
728
|
const info = await this.locate(sessionId);
|
|
686
729
|
return info ? summaryOf(info) : undefined;
|