@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.
- package/LICENSE +661 -0
- package/README.md +97 -0
- package/dist/agent/config.js +133 -0
- package/dist/agent/credentials.js +179 -0
- package/dist/agent/events.js +253 -0
- package/dist/agent/models.js +15 -0
- package/dist/agent/pi.js +296 -0
- package/dist/boards/boards.js +200 -0
- package/dist/boards/pier.css +445 -0
- package/dist/channels/chains.js +67 -0
- package/dist/channels/chunk.js +28 -0
- package/dist/channels/commands.js +28 -0
- package/dist/channels/config.js +172 -0
- package/dist/channels/control.js +71 -0
- package/dist/channels/conversations.js +65 -0
- package/dist/channels/gatekeeper.js +63 -0
- package/dist/channels/panel.js +233 -0
- package/dist/channels/receipts.js +104 -0
- package/dist/channels/routes.js +110 -0
- package/dist/channels/runtime.js +76 -0
- package/dist/channels/slack-api.js +296 -0
- package/dist/channels/slack-directory.js +77 -0
- package/dist/channels/slack-outbound.js +121 -0
- package/dist/channels/slack-panel.js +122 -0
- package/dist/channels/slack-render.js +214 -0
- package/dist/channels/slack-tool.js +334 -0
- package/dist/channels/slack.js +510 -0
- package/dist/channels/telegram-api.js +78 -0
- package/dist/channels/telegram-panel.js +113 -0
- package/dist/channels/telegram-render.js +96 -0
- package/dist/channels/telegram.js +473 -0
- package/dist/channels/types.js +27 -0
- package/dist/cli.js +101 -0
- package/dist/core/hub.js +53 -0
- package/dist/core/identity.js +66 -0
- package/dist/core/queue.js +11 -0
- package/dist/core/reply.js +202 -0
- package/dist/core/router.js +189 -0
- package/dist/core/types.js +7 -0
- package/dist/db.js +268 -0
- package/dist/log.js +55 -0
- package/dist/main.js +183 -0
- package/dist/paths.js +17 -0
- package/dist/secrets.js +191 -0
- package/dist/service.js +134 -0
- package/dist/settings.js +57 -0
- package/dist/tasks/agent.js +197 -0
- package/dist/tasks/callbacks.js +140 -0
- package/dist/tasks/command.js +74 -0
- package/dist/tasks/definitions.js +316 -0
- package/dist/tasks/execution.js +141 -0
- package/dist/tasks/groups.js +187 -0
- package/dist/tasks/messages.js +248 -0
- package/dist/tasks/routes.js +219 -0
- package/dist/tasks/runs.js +104 -0
- package/dist/tasks/service.js +282 -0
- package/dist/tasks/store.js +168 -0
- package/dist/tasks/tool.js +281 -0
- package/dist/tasks/types.js +5 -0
- package/dist/web/auth.js +280 -0
- package/dist/web/files.js +167 -0
- package/dist/web/public/assets/index-8CinH1uR.css +2 -0
- package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +19 -0
- package/dist/web/public/index.html +251 -0
- package/dist/web/public/manifest.webmanifest +16 -0
- package/dist/web/public/sw.js +21 -0
- package/dist/web/server.js +366 -0
- package/dist/web/session-state.js +39 -0
- package/docs/deploy.md +307 -0
- package/package.json +55 -0
- package/skills/pier-boards/SKILL.md +210 -0
- package/skills/pier-slack/SKILL.md +135 -0
- 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
|
+
}
|