@timqi/pier 0.0.1

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.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # Pier
2
+
3
+ A self-hosted workspace for coding agents. Pier puts a web workbench and your
4
+ IM channels in front of [Pi](https://github.com/earendil-works/pi) sessions:
5
+ you talk to the same agent from a browser, from Slack or from Telegram, steer a
6
+ running turn, schedule tasks, watch what every session is doing, and publish a
7
+ static page when something is worth showing.
8
+
9
+ One instance, one account, your own machine. The agent runs shell commands in
10
+ directories you name, so Pier is meant for a machine you own and a boundary you
11
+ control — not for a shared host.
12
+
13
+ **Status: pre-release.** The version is `0.0.x` and the database schema is
14
+ versioned from `0.0.1` on — earlier databases are not migrated. Read
15
+ `docs/deploy.md` before putting it anywhere reachable.
16
+
17
+ ## Requirements
18
+
19
+ - Node 24 or newer (`node:sqlite` is used unflagged)
20
+ - A Pi provider key (Anthropic, OpenAI, …) — Pi's own config, in
21
+ `~/.pi/agent/settings.json`, editable from Console → Configuration
22
+ - Optional: the `sqlite3` CLI, for backups and password resets
23
+
24
+ ## Run it
25
+
26
+ ```sh
27
+ npm install -g @timqi/pier
28
+ pier
29
+ ```
30
+
31
+ It listens on `127.0.0.1:3141` (`PORT`, `HOST`) and keeps everything under
32
+ `~/.pier` (`PIER_HOME`): one SQLite database and the boards it serves.
33
+
34
+ **The first start generates a password and prints it once.** Every HTTP surface
35
+ is behind it — there is no default password and no unclaimed window. Lost it?
36
+ `sqlite3 ~/.pier/pier.db 'DELETE FROM auth'` and restart; a new one is printed.
37
+
38
+ Open `http://localhost:3141`, sign in, then:
39
+
40
+ - **Console → Configuration** — provider keys and model defaults (Pi's files)
41
+ - **Console → Channels** — Slack (Socket Mode) or Telegram bot tokens; chats
42
+ are discovered when the bot first sees traffic, and stay gated by the
43
+ mention/bind rules you set
44
+ - **New session** — pick a directory; that is where the agent's shell runs
45
+
46
+ ## Run it as a service
47
+
48
+ ```sh
49
+ pier service install # --port, --host, --pier-home, --force
50
+ pier service status
51
+ pier service uninstall
52
+ ```
53
+
54
+ Linux only, because it is systemd. It writes `~/.config/systemd/user/pier.service`
55
+ with the absolute path of the node you installed with (systemd's PATH would not
56
+ find a version-managed one), a memory drop-in it never rewrites afterwards, and
57
+ turns on linger so scheduled tasks survive your logout. On macOS run `pier` in
58
+ a terminal, or under whatever supervisor you already use.
59
+
60
+ `docs/deploy.md` is the same thing written out by hand, plus what the memory
61
+ limits mean, how updates work (and why the updater is a second unit), how to
62
+ read the first-run password out of the journal, and what to back up.
63
+
64
+ Exposing it needs two things: a reverse proxy or tunnel that terminates TLS
65
+ (Pier binds the loopback and expects `X-Forwarded-For`/`-Proto`), and the
66
+ understanding that whoever gets past the password gets a shell.
67
+
68
+ ## Develop
69
+
70
+ ```sh
71
+ git clone https://github.com/timqi/pier.git ~/pier
72
+ cd ~/pier && npm ci && npm run build
73
+
74
+ just dev # build the web bundle, then tsx watch on PIER_HOME=~/.pier_test
75
+ npm run check # tsc, server and web
76
+ npm run lint # oxlint
77
+ npm test # vitest
78
+ ```
79
+
80
+ - `AGENTS.md` — the principles this codebase is held to, and the budgets that
81
+ say when a change is too big. Read it before writing code here.
82
+ - `docs/architecture.md` — the seams, the areas, and what is deliberately absent
83
+ - `docs/design/` — one document per subsystem, written before it was built
84
+
85
+ ## Releases
86
+
87
+ `main` is the only development line. `npm version minor` writes the tag, the
88
+ tag builds and publishes a GitHub Release, and the version in the web footer is
89
+ the one from `package.json` — so the number on screen always names a commit.
90
+ Schema upgrades are one-way: a database migrated by a newer Pier is refused by
91
+ an older one, so take the backup `docs/deploy.md` describes before upgrading.
92
+
93
+ ## License
94
+
95
+ [AGPL-3.0-only](LICENSE). Run it, change it, deploy it. If you offer a modified
96
+ Pier to other people over a network, they are entitled to your source — the
97
+ version in the footer links to this repository for exactly that reason.
@@ -0,0 +1,133 @@
1
+ // Pi configuration on disk, behind the ConfigStore seam. Knows Pi's directory
2
+ // conventions (the Pier-managed global dir; <cwd>/AGENTS.md and <cwd>/.pi per
3
+ // project) but not the Pi SDK — pure filesystem, unit-testable in a tmp dir.
4
+ import { promises as fs } from "node:fs";
5
+ import { join, resolve, sep } from "node:path";
6
+ import { pierPath } from "../paths.js";
7
+ const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
8
+ const PROJECT_FILES = ["AGENTS.md"];
9
+ const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
10
+ /** Pier owns the Pi runtime dir: config lives in the syncable `~/.pier/pi`
11
+ * repo, not `~/.pi`. main.ts exports this as PI_CODING_AGENT_DIR so the SDK's
12
+ * own path resolution (auth.json, sessions, bin) lands in the same place. */
13
+ export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
14
+ /** Stable mask: recomputable at write time, so "unchanged" is detectable. */
15
+ const maskKey = (key) => key.length > 8 ? `${key.slice(0, 4)}…${key.slice(-4)}` : "•••";
16
+ export class PiConfigStore {
17
+ agentDir;
18
+ constructor(agentDir = defaultAgentDir()) {
19
+ this.agentDir = agentDir;
20
+ }
21
+ /** Whitelist is the security boundary — nothing outside it is reachable. */
22
+ fileNames(scope) {
23
+ return scope.kind === "global" ? GLOBAL_FILES : PROJECT_FILES;
24
+ }
25
+ filePath(scope, name) {
26
+ if (!this.fileNames(scope).includes(name)) {
27
+ throw new Error(`not an editable config file: ${name}`);
28
+ }
29
+ return scope.kind === "global" ? join(this.agentDir, name) : join(scope.cwd, name);
30
+ }
31
+ resourceRoot(scope, kind) {
32
+ return scope.kind === "global"
33
+ ? join(this.agentDir, kind)
34
+ : join(scope.cwd, ".pi", kind);
35
+ }
36
+ async listFiles(scope) {
37
+ return Promise.all(this.fileNames(scope).map(async (name) => ({
38
+ name,
39
+ exists: await fs.access(this.filePath(scope, name)).then(() => true, () => false),
40
+ })));
41
+ }
42
+ async readFile(scope, name) {
43
+ const raw = await fs.readFile(this.filePath(scope, name), "utf8").catch(() => "");
44
+ return name === "models.json" ? maskModels(raw) : raw;
45
+ }
46
+ async writeFile(scope, name, content) {
47
+ const path = this.filePath(scope, name);
48
+ const data = name === "models.json"
49
+ ? unmaskModels(content, await fs.readFile(path, "utf8").catch(() => ""))
50
+ : content;
51
+ await fs.mkdir(scope.kind === "global" ? this.agentDir : scope.cwd, { recursive: true });
52
+ await fs.writeFile(path, data);
53
+ }
54
+ async listResources(scope) {
55
+ return {
56
+ extensions: await listDir(this.resourceRoot(scope, "extensions")),
57
+ skills: await listDir(this.resourceRoot(scope, "skills")),
58
+ };
59
+ }
60
+ async readResource(scope, kind, name) {
61
+ const root = this.resourceRoot(scope, kind);
62
+ const path = resolve(root, name);
63
+ // Containment check — the listing is relative paths, reject anything else.
64
+ if (!path.startsWith(root + sep))
65
+ throw new Error(`invalid resource path: ${name}`);
66
+ return fs.readFile(path, "utf8");
67
+ }
68
+ }
69
+ /**
70
+ * Relative paths of all files under root, bounded depth, sorted; [] if absent.
71
+ * Symlinks are followed (skills and extensions are routinely linked in from a
72
+ * checkout elsewhere) and everything reached through one is flagged, so the UI
73
+ * can say where it really came from. The depth bound is also the cycle guard.
74
+ */
75
+ async function listDir(root, prefix = "", depth = RESOURCE_DEPTH, linked = false) {
76
+ if (depth === 0)
77
+ return [];
78
+ const entries = await fs.readdir(join(root, prefix), { withFileTypes: true }).catch(() => []);
79
+ const out = [];
80
+ for (const e of entries) {
81
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
82
+ const link = linked || e.isSymbolicLink();
83
+ // A Dirent for a symlink is neither file nor directory — stat through it.
84
+ const target = e.isSymbolicLink()
85
+ ? await fs.stat(join(root, rel)).catch(() => null) // dangling link → skip
86
+ : e;
87
+ if (target?.isDirectory())
88
+ out.push(...(await listDir(root, rel, depth - 1, link)));
89
+ else if (target?.isFile())
90
+ out.push({ name: rel, link });
91
+ }
92
+ return out.sort((a, b) => a.name.localeCompare(b.name));
93
+ }
94
+ /**
95
+ * models.json carries provider API keys — the UI must never see them. A file
96
+ * that fails to parse is passed through untouched: the user needs to see the
97
+ * broken content to repair it, and the keys in it are their own.
98
+ */
99
+ function maskModels(raw) {
100
+ const parsed = parseModels(raw);
101
+ if (!parsed)
102
+ return raw;
103
+ for (const p of Object.values(parsed.providers ?? {})) {
104
+ if (typeof p.apiKey === "string" && p.apiKey)
105
+ p.apiKey = maskKey(p.apiKey);
106
+ }
107
+ return JSON.stringify(parsed, null, 2);
108
+ }
109
+ /** Restore stored keys wherever the incoming value is still the mask. */
110
+ function unmaskModels(content, currentRaw) {
111
+ const incoming = parseModels(content);
112
+ if (!incoming)
113
+ throw new Error("models.json must be valid JSON");
114
+ const current = parseModels(currentRaw);
115
+ for (const [name, p] of Object.entries(incoming.providers ?? {})) {
116
+ const stored = current?.providers?.[name]?.apiKey;
117
+ if (typeof p.apiKey === "string" &&
118
+ typeof stored === "string" &&
119
+ p.apiKey === maskKey(stored)) {
120
+ p.apiKey = stored;
121
+ }
122
+ }
123
+ return JSON.stringify(incoming, null, 2);
124
+ }
125
+ function parseModels(raw) {
126
+ try {
127
+ const parsed = JSON.parse(raw);
128
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
@@ -0,0 +1,179 @@
1
+ // Provider credentials — what Pi kept in <agentDir>/auth.json, and the
2
+ // literal API keys models.json used to carry — at rest in pier.db, sealed by
3
+ // Secrets. Implements pi-ai's CredentialStore contract structurally (shapes
4
+ // mirrored below, no SDK import: only pi.ts names SDK modules), so
5
+ // ModelRuntime reads through here and an OAuth refresh writes the rotated
6
+ // token back through here instead of a file. A stored credential wins over a
7
+ // models.json apiKey in pi-ai's resolution order, which is what lets the
8
+ // sweep below leave models.json purely structural — and safely syncable.
9
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { logger } from "../log.js";
12
+ import { defaultAgentDir } from "./config.js";
13
+ const log = logger("credentials");
14
+ /** Envelope from secrets.ts. A value that matches was sealed by us; anything
15
+ * else is legacy plaintext, still honored and re-sealed on the next write. */
16
+ const SEALED = /^v1:[0-9a-f]{8}:/;
17
+ export class CredentialStore {
18
+ db;
19
+ secrets;
20
+ agentDir;
21
+ #imported = false;
22
+ /** Writes run one at a time: pi-ai refreshes OAuth tokens inside modify()
23
+ * and relies on it being a serialized read-modify-write. */
24
+ #chain = Promise.resolve();
25
+ constructor(db, secrets, agentDir = defaultAgentDir()) {
26
+ this.db = db;
27
+ this.secrets = secrets;
28
+ this.agentDir = agentDir;
29
+ }
30
+ /** Locked Secrets must fail a session open loudly, with the reason — not
31
+ * surface later as "provider is not configured". Called by pi.ts before
32
+ * every open; encrypt() throws the `secrets locked: ...` error we want. */
33
+ assertUnlocked() {
34
+ if (this.secrets.state === "locked")
35
+ this.secrets.encrypt("");
36
+ }
37
+ async read(providerId, options) {
38
+ options?.signal?.throwIfAborted();
39
+ this.#ensureImported();
40
+ return this.#get(providerId);
41
+ }
42
+ async list(options) {
43
+ options?.signal?.throwIfAborted();
44
+ this.#ensureImported();
45
+ const rows = this.db.prepare("SELECT key, value FROM credentials").all();
46
+ return rows.map((row) => ({ providerId: row.key, type: this.#parse(row.value).type }));
47
+ }
48
+ async modify(providerId, fn, options) {
49
+ return this.#serialized(options, async () => {
50
+ const next = await fn(this.#get(providerId));
51
+ options?.signal?.throwIfAborted();
52
+ if (next === undefined)
53
+ return this.#get(providerId);
54
+ this.#put(providerId, next);
55
+ return next;
56
+ });
57
+ }
58
+ async delete(providerId, options) {
59
+ return this.#serialized(options, async () => {
60
+ this.db.prepare("DELETE FROM credentials WHERE key = ?").run(providerId);
61
+ });
62
+ }
63
+ #serialized(options, run) {
64
+ const op = this.#chain.then(() => {
65
+ options?.signal?.throwIfAborted();
66
+ this.#ensureImported();
67
+ return run();
68
+ },
69
+ // The chain only sequences; a predecessor's failure is its caller's news.
70
+ () => {
71
+ options?.signal?.throwIfAborted();
72
+ this.#ensureImported();
73
+ return run();
74
+ });
75
+ this.#chain = op.catch(() => { });
76
+ return op;
77
+ }
78
+ #get(providerId) {
79
+ const row = this.db.prepare("SELECT value FROM credentials WHERE key = ?").get(providerId);
80
+ return row ? this.#parse(row.value) : undefined;
81
+ }
82
+ #parse(value) {
83
+ const plain = SEALED.test(value) ? this.secrets.decrypt(value) : value;
84
+ return JSON.parse(plain);
85
+ }
86
+ #put(providerId, credential) {
87
+ this.db
88
+ .prepare("INSERT INTO credentials(key, value) VALUES (?, ?) " +
89
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value")
90
+ .run(providerId, this.secrets.encrypt(JSON.stringify(credential)));
91
+ }
92
+ /** One-time move of <agentDir>/auth.json into the database. Lazy because
93
+ * sealing needs an unlocked Secrets; retried until it succeeds (the flag is
94
+ * set only then, and #put is an idempotent upsert). The file is renamed,
95
+ * never deleted: auth.json.imported is the operator's receipt and way back. */
96
+ #ensureImported() {
97
+ if (this.#imported)
98
+ return;
99
+ const path = join(this.agentDir, "auth.json");
100
+ if (existsSync(path)) {
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(readFileSync(path, "utf8"));
104
+ }
105
+ catch (err) {
106
+ throw new Error(`${path} exists but is not valid JSON — fix or move it: ${String(err)}`, {
107
+ cause: err,
108
+ });
109
+ }
110
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
111
+ throw new Error(`${path} exists but is not an object — fix or move it`);
112
+ }
113
+ const entries = Object.entries(parsed);
114
+ for (const [providerId, credential] of entries)
115
+ this.#put(providerId, credential);
116
+ renameSync(path, `${path}.imported`);
117
+ log.info(`imported ${entries.length} provider credential(s) from ${path}, renamed to auth.json.imported`);
118
+ }
119
+ this.#sweepModelsJson();
120
+ this.#imported = true;
121
+ }
122
+ /**
123
+ * models.json apiKeys are secrets in a file that should be pure structure
124
+ * (it is what a config repo syncs between hosts). Literal keys move into the
125
+ * database; `!command` and `$ENV` references are already not plaintext and
126
+ * stay — the SDK resolves those forms itself, and a sealed copy of a
127
+ * reference would freeze its meaning. The pre-sweep file is kept whole as
128
+ * models.json.imported: keys leave the file only with a receipt.
129
+ */
130
+ #sweepModelsJson() {
131
+ const path = join(this.agentDir, "models.json");
132
+ if (!existsSync(path))
133
+ return;
134
+ const raw = readFileSync(path, "utf8");
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ }
139
+ catch (err) {
140
+ // Not this sweep's failure to own: the SDK will refuse the same file
141
+ // loudly. Named here so "my key is still in the file" has an explanation.
142
+ log.warn(`${path} is not valid JSON — API keys not swept: ${String(err)}`);
143
+ return;
144
+ }
145
+ const providers = typeof parsed === "object" && parsed !== null
146
+ ? parsed.providers
147
+ : undefined;
148
+ if (!providers)
149
+ return;
150
+ const moved = [];
151
+ const shadowed = [];
152
+ for (const [providerId, provider] of Object.entries(providers)) {
153
+ const key = provider.apiKey;
154
+ // Literals only: `!cmd` runs a command, `$` marks env expansion (`$$`
155
+ // escapes a literal dollar — rare enough to leave in place).
156
+ if (typeof key !== "string" || !key || key.startsWith("!") || key.includes("$"))
157
+ continue;
158
+ if (this.#get(providerId)) {
159
+ // A stored credential short-circuits resolution, so this file copy was
160
+ // already dead — removed, not imported, and the receipt keeps it.
161
+ shadowed.push(providerId);
162
+ }
163
+ else {
164
+ this.#put(providerId, { type: "api_key", key });
165
+ moved.push(providerId);
166
+ }
167
+ delete provider.apiKey;
168
+ }
169
+ if (moved.length === 0 && shadowed.length === 0)
170
+ return;
171
+ writeFileSync(`${path}.imported`, raw, { mode: 0o600 });
172
+ writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
173
+ if (moved.length)
174
+ log.info(`moved ${moved.length} literal API key(s) from models.json into pier.db: ${moved.join(", ")}`);
175
+ if (shadowed.length)
176
+ log.info(`removed ${shadowed.length} shadowed models.json API key(s) (a stored credential already wins): ${shadowed.join(", ")}`);
177
+ log.info(`pre-sweep models.json kept as ${path}.imported`);
178
+ }
179
+ }
@@ -0,0 +1,253 @@
1
+ // Pure Pi-event → SessionEventPayload translation. Structurally typed on
2
+ // purpose: no @earendil-works/pi-* imports, so it stays unit-testable without Pi
3
+ // and Pi types never leak past the seam. The golden-table test in
4
+ // events.test.ts is the mapping's spec; extend types.ts before adding events.
5
+ export function textOf(content) {
6
+ if (typeof content === "string")
7
+ return content;
8
+ if (!Array.isArray(content))
9
+ return "";
10
+ return content
11
+ .filter((p) => p.type === "text" && typeof p.text === "string")
12
+ .map((p) => p.text)
13
+ .join("");
14
+ }
15
+ function imageParts(m) {
16
+ return Array.isArray(m.content) ? m.content.filter((p) => p.type === "image") : [];
17
+ }
18
+ /** Renderable text of a message, with an image marker (IM can't show them). */
19
+ function displayText(m) {
20
+ const text = textOf(m.content);
21
+ const images = imageParts(m).length;
22
+ if (!images)
23
+ return text;
24
+ return `${text}${text ? " " : ""}[${images} image${images > 1 ? "s" : ""}]`;
25
+ }
26
+ function systemOrigin(message) {
27
+ if (message.role !== "custom" || message.customType !== "pier.system-input")
28
+ return null;
29
+ const value = message.details;
30
+ if (!value || typeof value !== "object")
31
+ return null;
32
+ const origin = value;
33
+ if (typeof origin.taskId !== "string" ||
34
+ typeof origin.runId !== "string" ||
35
+ (origin.sourceSessionId !== null && typeof origin.sourceSessionId !== "string"))
36
+ return null;
37
+ if (origin.kind === "task-delegation" || origin.kind === "task-callback") {
38
+ return origin;
39
+ }
40
+ if (origin.kind === "task-message" &&
41
+ typeof origin.messageId === "string" &&
42
+ (origin.messageKind === "steer" || origin.messageKind === "follow_up" ||
43
+ origin.messageKind === "progress" || origin.messageKind === "decision" || origin.messageKind === "reply"))
44
+ return origin;
45
+ return null;
46
+ }
47
+ /**
48
+ * Bytes of the ordinal-th transcript image. Walks user/assistant messages in
49
+ * the same order toChatTurns numbers them — the two must not drift.
50
+ */
51
+ export function imageAt(messages, ordinal) {
52
+ let n = 0;
53
+ for (const m of messages) {
54
+ if (m.role !== "user" && m.role !== "assistant")
55
+ continue;
56
+ for (const p of imageParts(m)) {
57
+ if (n++ !== ordinal)
58
+ continue;
59
+ return p.data ? { data: p.data, mimeType: p.mimeType ?? "image/png" } : undefined;
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ function lastAssistant(messages) {
65
+ if (!messages)
66
+ return undefined;
67
+ for (let i = messages.length - 1; i >= 0; i--) {
68
+ if (messages[i]?.role === "assistant")
69
+ return messages[i];
70
+ }
71
+ return undefined;
72
+ }
73
+ /**
74
+ * Completion metadata for the assistant message at `index` (bubble hover
75
+ * hints). `completedAt` defaults to the message's own timestamp — Pi stamps
76
+ * that at stream start, so callers with a real clock (live turn-end) pass
77
+ * their own; history accepts the approximation.
78
+ *
79
+ * `tokens` is the context size at that point, not a sum: each assistant
80
+ * message's `totalTokens` already covers the whole request (prompt + cache +
81
+ * output), so adding them up double-counts the context on every turn. Pi
82
+ * itself reads context usage off the last assistant message the same way.
83
+ */
84
+ export function turnMetaAt(messages, index, completedAt) {
85
+ const m = messages[index];
86
+ if (m?.role !== "assistant" || typeof m.timestamp !== "number")
87
+ return undefined;
88
+ const end = completedAt ?? m.timestamp;
89
+ let started = end;
90
+ for (let i = index - 1; i >= 0; i--) {
91
+ const t = messages[i];
92
+ if (t && (t.role === "user" || systemOrigin(t) !== null) && typeof t.timestamp === "number") {
93
+ started = t.timestamp;
94
+ break;
95
+ }
96
+ }
97
+ let tokens = 0;
98
+ for (let i = index; i >= 0; i--) {
99
+ const t = messages[i];
100
+ if (t?.role === "assistant" && t.usage?.totalTokens) {
101
+ tokens = t.usage.totalTokens;
102
+ break;
103
+ }
104
+ }
105
+ return { completedAt: end, durationMs: Math.max(0, end - started), tokens };
106
+ }
107
+ /**
108
+ * Rebuild the renderable transcript: user/assistant turns plus the activity
109
+ * (thinking + tool calls) that preceded each assistant answer. This is what
110
+ * makes a page reload show the real step counts instead of restarting at zero.
111
+ */
112
+ export function toChatTurns(messages) {
113
+ const turns = [];
114
+ let steps = []; // activity seen since the last emitted turn
115
+ const pendingTools = new Map();
116
+ let ordinal = 0; // must advance exactly like imageAt() walks the messages
117
+ const flush = (role, text, meta, origin, images) => {
118
+ const turn = { role, text };
119
+ if (meta)
120
+ turn.meta = meta;
121
+ if (origin)
122
+ turn.origin = origin;
123
+ if (steps.length) {
124
+ turn.steps = steps;
125
+ steps = [];
126
+ }
127
+ if (images?.length)
128
+ turn.images = images;
129
+ turns.push(turn);
130
+ };
131
+ for (const [i, m] of messages.entries()) {
132
+ if (m.role === "toolResult") {
133
+ const step = pendingTools.get(m.toolCallId ?? "");
134
+ if (step) {
135
+ step.output = textOf(m.content);
136
+ step.isError = m.isError ?? false;
137
+ pendingTools.delete(m.toolCallId ?? "");
138
+ }
139
+ continue;
140
+ }
141
+ const origin = systemOrigin(m);
142
+ if (origin) {
143
+ const text = displayText(m);
144
+ if (text)
145
+ flush("system", text, undefined, origin);
146
+ continue;
147
+ }
148
+ if (m.role !== "user" && m.role !== "assistant")
149
+ continue;
150
+ if (m.role === "assistant" && Array.isArray(m.content)) {
151
+ for (const part of m.content) {
152
+ if (part.type === "thinking" && part.thinking) {
153
+ steps.push({ kind: "thinking", text: part.thinking });
154
+ }
155
+ else if (part.type === "toolCall") {
156
+ const step = {
157
+ kind: "tool",
158
+ id: part.id,
159
+ toolName: part.name ?? "",
160
+ args: part.arguments,
161
+ };
162
+ steps.push(step);
163
+ if (part.id)
164
+ pendingTools.set(part.id, step);
165
+ }
166
+ }
167
+ }
168
+ // Refs, not bytes: the web fetches each image from its own route.
169
+ const images = imageParts(m).map((p) => ({
170
+ mimeType: p.mimeType ?? "image/png",
171
+ ordinal: ordinal++,
172
+ }));
173
+ const text = textOf(m.content);
174
+ // step-only assistant messages keep buffering activity
175
+ if (!text && !images.length)
176
+ continue;
177
+ flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined, undefined, images);
178
+ }
179
+ // Activity with no answer after it (aborted run) still belongs on the page.
180
+ if (steps.length)
181
+ flush("assistant", "");
182
+ return turns;
183
+ }
184
+ /** Translate one Pi session event into zero or more Pier payloads. */
185
+ export function toSessionEvents(e) {
186
+ switch (e.type) {
187
+ case "agent_start":
188
+ return [{ type: "state", state: "streaming" }, { type: "turn-start" }];
189
+ case "agent_end": {
190
+ const final = lastAssistant(e.messages);
191
+ const out = [
192
+ { type: "turn-end", text: textOf(final?.content) },
193
+ ];
194
+ if (final?.stopReason === "error") {
195
+ out.push({ type: "error", message: final.errorMessage ?? "unknown agent error" });
196
+ }
197
+ out.push({ type: "state", state: "idle" });
198
+ return out;
199
+ }
200
+ case "message_start": {
201
+ // Pi emits this for every message entering the context; the user ones are
202
+ // what a client can't know about (queued/steered messages, IM traffic).
203
+ const m = e.message;
204
+ if (!m)
205
+ return [];
206
+ const origin = systemOrigin(m);
207
+ const text = displayText(m);
208
+ if (origin)
209
+ return text ? [{ type: "system-input", text, origin }] : [];
210
+ if (m.role !== "user")
211
+ return [];
212
+ return text ? [{ type: "user-message", text }] : [];
213
+ }
214
+ case "message_update": {
215
+ const ame = e.assistantMessageEvent;
216
+ if (ame?.type === "text_delta" && ame.delta) {
217
+ return [{ type: "text-delta", text: ame.delta }];
218
+ }
219
+ if (ame?.type === "thinking_delta" && ame.delta) {
220
+ return [{ type: "thinking-delta", text: ame.delta }];
221
+ }
222
+ return [];
223
+ }
224
+ case "tool_execution_start":
225
+ return [
226
+ {
227
+ type: "tool-start",
228
+ toolCallId: e.toolCallId ?? "",
229
+ toolName: e.toolName ?? "",
230
+ args: e.args,
231
+ },
232
+ ];
233
+ case "queue_update":
234
+ return [
235
+ {
236
+ type: "queue-state",
237
+ steering: [...(e.steering ?? [])],
238
+ followUp: [...(e.followUp ?? [])],
239
+ },
240
+ ];
241
+ case "tool_execution_end":
242
+ return [
243
+ {
244
+ type: "tool-end",
245
+ toolCallId: e.toolCallId ?? "",
246
+ isError: e.isError ?? false,
247
+ output: textOf(e.result?.content),
248
+ },
249
+ ];
250
+ default:
251
+ return [];
252
+ }
253
+ }
@@ -0,0 +1,15 @@
1
+ // Pure model-list curation, unit-testable without Pi. The raw registry
2
+ // returns every catalog entry per authed provider (legacy generations, dated
3
+ // aliases, -latest aliases); the picker wants the common working set.
4
+ export function curateModels(models) {
5
+ const ids = new Set(models.map((m) => `${m.provider}/${m.id}`));
6
+ return models
7
+ .filter((m) => m.reasoning) // agent work wants reasoning-capable models
8
+ .filter((m) => !m.id.endsWith("-latest")) // alias noise
9
+ .filter((m) => {
10
+ // Drop dated variants when the undated alias is also in the catalog.
11
+ const undated = m.id.replace(/-\d{8}$/, "");
12
+ return undated === m.id || !ids.has(`${m.provider}/${undated}`);
13
+ })
14
+ .map(({ provider, id }) => ({ provider, id }));
15
+ }