@timqi/pier 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -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 +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/README.md
CHANGED
|
@@ -17,8 +17,9 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
19
19
|
- Node 24 or newer (`node:sqlite` is used unflagged)
|
|
20
|
-
- A
|
|
21
|
-
|
|
20
|
+
- A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
|
|
21
|
+
login from Console → Settings → Providers after signing in
|
|
22
|
+
- A user-writable global npm prefix if `pier update` should update a service
|
|
22
23
|
- Optional: the `sqlite3` CLI, for backups and password resets
|
|
23
24
|
|
|
24
25
|
## Run it
|
|
@@ -29,33 +30,87 @@ pier
|
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
It listens on `127.0.0.1:3141` (`PORT`, `HOST`) and keeps everything under
|
|
32
|
-
`~/.pier` (`PIER_HOME`): one SQLite database
|
|
33
|
+
`~/.pier` (`PIER_HOME`): one SQLite database, the boards it serves, the
|
|
34
|
+
`master.key` that seals stored credentials, and the Pi runtime with its
|
|
35
|
+
session transcripts (`~/.pier/pi`, unless `PI_CODING_AGENT_DIR` says
|
|
36
|
+
otherwise).
|
|
33
37
|
|
|
34
38
|
**The first start generates a password and prints it once.** Every HTTP surface
|
|
35
39
|
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.
|
|
40
|
+
`sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart; a new one is printed.
|
|
37
41
|
|
|
38
42
|
Open `http://localhost:3141`, sign in, then:
|
|
39
43
|
|
|
40
|
-
- **Console →
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
- **Console → Settings** — everything the instance is configured with, one
|
|
45
|
+
tab per topic: Providers (API-key/OAuth logins), Models (the menu of
|
|
46
|
+
favored models agents are advised with), Channels (Slack Socket-Mode or
|
|
47
|
+
Telegram bot tokens; chats are discovered when the bot first sees traffic,
|
|
48
|
+
gated by the mention/bind rules you set), Agent files (Pi configuration),
|
|
49
|
+
plus the public URL, password and master key
|
|
44
50
|
- **New session** — pick a directory; that is where the agent's shell runs
|
|
45
51
|
|
|
52
|
+
## Configure Pi
|
|
53
|
+
|
|
54
|
+
Pier gives Pi a dedicated agent directory instead of changing your normal Pi
|
|
55
|
+
installation. By default it is `$PIER_HOME/pi` (`~/.pier/pi`). Set
|
|
56
|
+
`PI_CODING_AGENT_DIR` before starting Pier to use another directory, including
|
|
57
|
+
an existing Pi setup:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Console → Settings is the normal setup path:
|
|
64
|
+
|
|
65
|
+
- **Providers** configures built-in or custom endpoints and API-key/OAuth login.
|
|
66
|
+
Stored credentials are sealed in Pier's SQLite database; they are not written
|
|
67
|
+
back to `models.json`.
|
|
68
|
+
- **Agent files** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
|
|
69
|
+
`models.json` structure in the Pi agent directory — globally, or per project
|
|
70
|
+
scope, where it also shows that project's `.pi/skills` and `.pi/extensions`
|
|
71
|
+
resources. Changes apply when a session next opens; `pier reload` recycles
|
|
72
|
+
idle, unwatched sessions so their next message uses the current files.
|
|
73
|
+
|
|
74
|
+
On first credential access, Pier imports an existing `auth.json` into its sealed
|
|
75
|
+
store and renames the source to `auth.json.imported`. Literal provider keys left
|
|
76
|
+
in `models.json` are moved the same way, with the original retained as
|
|
77
|
+
`models.json.imported`. Use Providers for new secrets; the advanced editor will
|
|
78
|
+
not accept plaintext keys or header values.
|
|
79
|
+
|
|
80
|
+
Provider environment variables supported by Pi are inherited from the Pier
|
|
81
|
+
process. A service installed with `pier service install` does not inherit your
|
|
82
|
+
interactive shell, so put non-secret Pi environment settings in a systemd unit
|
|
83
|
+
override; for API keys, prefer the sealed Providers UI.
|
|
84
|
+
|
|
46
85
|
## Run it as a service
|
|
47
86
|
|
|
48
87
|
```sh
|
|
49
88
|
pier service install # --port, --host, --pier-home, --force
|
|
50
89
|
pier service status
|
|
51
90
|
pier service uninstall
|
|
91
|
+
pier restart # drain running work, then restart
|
|
92
|
+
pier reload # re-read channel config and recycle idle sessions
|
|
93
|
+
pier backup # snapshot the database before a manual update
|
|
94
|
+
pier update # latest release, then hard-stop/restart the service
|
|
52
95
|
```
|
|
53
96
|
|
|
97
|
+
`pier restart` refuses new work, waits up to five minutes for active turns and
|
|
98
|
+
Task runs, then restarts; if its deadline aborts an IM turn, the next process
|
|
99
|
+
tells that conversation. `pier reload` stays in-process: adapters re-read their
|
|
100
|
+
configuration and idle, unwatched sessions reopen on their next message.
|
|
101
|
+
Streaming or watched sessions keep running and pick changes up after eviction.
|
|
102
|
+
Both commands target the installed systemd service. `pier update` is still a
|
|
103
|
+
hard stop because its separate updater replaces the installed code; let active
|
|
104
|
+
work finish before starting it.
|
|
105
|
+
|
|
54
106
|
Linux only, because it is systemd. It writes `~/.config/systemd/user/pier.service`
|
|
55
107
|
with the absolute path of the node you installed with (systemd's PATH would not
|
|
56
108
|
find a version-managed one), a memory drop-in it never rewrites afterwards, and
|
|
57
|
-
turns on linger so scheduled tasks survive your logout.
|
|
58
|
-
a
|
|
109
|
+
turns on linger so scheduled tasks survive your logout. Install also records the
|
|
110
|
+
exact npm executable in a separate updater unit. Re-run `pier service install
|
|
111
|
+
--force` after changing the service settings or its Node/npm installation; this
|
|
112
|
+
rewrites both units and restarts Pier. On macOS run `pier` in a terminal, or
|
|
113
|
+
under whatever supervisor you already use.
|
|
59
114
|
|
|
60
115
|
`docs/deploy.md` is the same thing written out by hand, plus what the memory
|
|
61
116
|
limits mean, how updates work (and why the updater is a second unit), how to
|
|
@@ -84,11 +139,20 @@ npm test # vitest
|
|
|
84
139
|
|
|
85
140
|
## Releases
|
|
86
141
|
|
|
87
|
-
`
|
|
142
|
+
Pier checks `registry.npmjs.org` in the background and shows `v0.0.1 → 0.0.2`
|
|
143
|
+
in the footer when a release is out. It never updates automatically: this
|
|
144
|
+
process holds provider keys and can run a shell, so rewriting its own code on a
|
|
145
|
+
timer would be a supply-chain surface. `pier update` is a command someone
|
|
146
|
+
types; unlike `pier restart`, it hard-stops the service, writes
|
|
147
|
+
`~/.pier/db/pier.db.release.bak`, updates the npm installation recorded when
|
|
148
|
+
the service was installed, and starts Pier again.
|
|
149
|
+
|
|
150
|
+
`main` is the only development line. `npm version patch` writes the tag, the
|
|
88
151
|
tag builds and publishes a GitHub Release, and the version in the web footer is
|
|
89
152
|
the one from `package.json` — so the number on screen always names a commit.
|
|
90
153
|
Schema upgrades are one-way: a database migrated by a newer Pier is refused by
|
|
91
|
-
an older one
|
|
154
|
+
an older one. The release backup above is the way back; `docs/deploy.md` has the
|
|
155
|
+
restore procedure and the additional snapshots taken before schema migrations.
|
|
92
156
|
|
|
93
157
|
## License
|
|
94
158
|
|
package/dist/agent/config.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Pi configuration on disk, behind the ConfigStore seam. Knows Pi's directory
|
|
2
2
|
// conventions (the Pier-managed global dir; <cwd>/AGENTS.md and <cwd>/.pi per
|
|
3
3
|
// project) but not the Pi SDK — pure filesystem, unit-testable in a tmp dir.
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { promises as fs } from "node:fs";
|
|
5
6
|
import { join, resolve, sep } from "node:path";
|
|
7
|
+
import { isProviderApi, validateEndpoint, validateProviderSetup } from "../core/types.js";
|
|
6
8
|
import { pierPath } from "../paths.js";
|
|
7
9
|
const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
|
|
8
10
|
const PROJECT_FILES = ["AGENTS.md"];
|
|
@@ -11,13 +13,56 @@ const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
|
|
|
11
13
|
* repo, not `~/.pi`. main.ts exports this as PI_CODING_AGENT_DIR so the SDK's
|
|
12
14
|
* own path resolution (auth.json, sessions, bin) lands in the same place. */
|
|
13
15
|
export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
|
|
14
|
-
/** Stable mask:
|
|
15
|
-
const maskKey = (
|
|
16
|
+
/** Stable mask: mapped back by field, without exposing key fragments. */
|
|
17
|
+
const maskKey = (_key) => "••••••••";
|
|
18
|
+
const missing = (err) => err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
19
|
+
const readOptional = async (path) => {
|
|
20
|
+
try {
|
|
21
|
+
return await fs.readFile(path, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (missing(err))
|
|
25
|
+
return "";
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const pathExists = async (path) => {
|
|
30
|
+
try {
|
|
31
|
+
await fs.access(path);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (missing(err))
|
|
36
|
+
return false;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const atomicWrite = async (path, data, mode = 0o644) => {
|
|
41
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
await fs.writeFile(temp, data, { mode });
|
|
44
|
+
await fs.rename(temp, path);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
try {
|
|
48
|
+
await fs.unlink(temp);
|
|
49
|
+
}
|
|
50
|
+
catch (cleanup) {
|
|
51
|
+
if (!missing(cleanup))
|
|
52
|
+
throw new AggregateError([err, cleanup], `failed to write ${path}`);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
16
57
|
export class PiConfigStore {
|
|
17
58
|
agentDir;
|
|
59
|
+
#writes = Promise.resolve();
|
|
18
60
|
constructor(agentDir = defaultAgentDir()) {
|
|
19
61
|
this.agentDir = agentDir;
|
|
20
62
|
}
|
|
63
|
+
get globalDir() {
|
|
64
|
+
return this.agentDir;
|
|
65
|
+
}
|
|
21
66
|
/** Whitelist is the security boundary — nothing outside it is reachable. */
|
|
22
67
|
fileNames(scope) {
|
|
23
68
|
return scope.kind === "global" ? GLOBAL_FILES : PROJECT_FILES;
|
|
@@ -36,20 +81,128 @@ export class PiConfigStore {
|
|
|
36
81
|
async listFiles(scope) {
|
|
37
82
|
return Promise.all(this.fileNames(scope).map(async (name) => ({
|
|
38
83
|
name,
|
|
39
|
-
exists: await
|
|
84
|
+
exists: await pathExists(this.filePath(scope, name)),
|
|
40
85
|
})));
|
|
41
86
|
}
|
|
42
87
|
async readFile(scope, name) {
|
|
43
|
-
const
|
|
88
|
+
const path = this.filePath(scope, name);
|
|
89
|
+
await this.#writes;
|
|
90
|
+
const raw = await readOptional(path);
|
|
44
91
|
return name === "models.json" ? maskModels(raw) : raw;
|
|
45
92
|
}
|
|
46
|
-
async writeFile(scope, name, content) {
|
|
93
|
+
async writeFile(scope, name, content, expected) {
|
|
47
94
|
const path = this.filePath(scope, name);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
:
|
|
51
|
-
|
|
52
|
-
|
|
95
|
+
return this.#withWrite(async () => {
|
|
96
|
+
const current = await readOptional(path);
|
|
97
|
+
const visible = name === "models.json" ? maskModels(current) : current;
|
|
98
|
+
if (expected !== undefined && visible !== expected)
|
|
99
|
+
throw new Error(`${name} changed on disk; reopen it`);
|
|
100
|
+
const data = name === "models.json" ? unmaskModels(content, current) : content;
|
|
101
|
+
await fs.mkdir(scope.kind === "global" ? this.agentDir : scope.cwd, { recursive: true });
|
|
102
|
+
await atomicWrite(path, data, name === "models.json" ? 0o600 : 0o644);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async providerStructures() {
|
|
106
|
+
await this.#writes;
|
|
107
|
+
const raw = await readOptional(join(this.agentDir, "models.json"));
|
|
108
|
+
const parsed = parseModels(raw);
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [id, provider] of Object.entries(parsed?.providers ?? {})) {
|
|
111
|
+
const api = isProviderApi(provider.api) ? provider.api : undefined;
|
|
112
|
+
const endpoint = typeof provider.baseUrl === "string" ? provider.baseUrl : undefined;
|
|
113
|
+
if (endpoint)
|
|
114
|
+
validateEndpoint(endpoint);
|
|
115
|
+
const models = Array.isArray(provider.models)
|
|
116
|
+
? provider.models.flatMap((model) => {
|
|
117
|
+
if (typeof model !== "object" || model === null || typeof model.id !== "string") {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
return [{
|
|
121
|
+
id: model.id,
|
|
122
|
+
reasoning: model.reasoning === true,
|
|
123
|
+
}];
|
|
124
|
+
})
|
|
125
|
+
: undefined;
|
|
126
|
+
out[id] = {
|
|
127
|
+
...(typeof provider.name === "string" ? { name: provider.name } : {}),
|
|
128
|
+
...(endpoint ? { endpoint } : {}),
|
|
129
|
+
...(api ? { api } : {}),
|
|
130
|
+
...(models ? { models } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
async setupProvider(input, verify) {
|
|
136
|
+
validateProviderSetup(input);
|
|
137
|
+
await this.#withWrite(async () => {
|
|
138
|
+
const path = join(this.agentDir, "models.json");
|
|
139
|
+
const existed = await pathExists(path);
|
|
140
|
+
const raw = await readOptional(path);
|
|
141
|
+
const parsed = raw.trim() ? parseModels(raw) : {};
|
|
142
|
+
if (!parsed)
|
|
143
|
+
throw new Error("models.json must be valid JSON before configuring a provider");
|
|
144
|
+
const providers = { ...parsed.providers };
|
|
145
|
+
const current = { ...providers[input.id] };
|
|
146
|
+
if (input.kind === "builtin") {
|
|
147
|
+
if (input.endpoint)
|
|
148
|
+
current.baseUrl = input.endpoint;
|
|
149
|
+
else
|
|
150
|
+
delete current.baseUrl;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
if (input.name)
|
|
154
|
+
current.name = input.name;
|
|
155
|
+
else
|
|
156
|
+
delete current.name;
|
|
157
|
+
current.baseUrl = input.endpoint;
|
|
158
|
+
current.api = input.api;
|
|
159
|
+
const existingModels = Array.isArray(current.models) ? current.models : [];
|
|
160
|
+
current.models = input.models.map((model) => {
|
|
161
|
+
const existing = existingModels.find((candidate) => typeof candidate === "object" && candidate !== null &&
|
|
162
|
+
candidate.id === model.id);
|
|
163
|
+
const next = {
|
|
164
|
+
...existing, id: model.id,
|
|
165
|
+
};
|
|
166
|
+
if (model.reasoning)
|
|
167
|
+
next.reasoning = true;
|
|
168
|
+
else
|
|
169
|
+
delete next.reasoning;
|
|
170
|
+
return next;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(current).length)
|
|
174
|
+
providers[input.id] = current;
|
|
175
|
+
else
|
|
176
|
+
delete providers[input.id];
|
|
177
|
+
const candidate = `${JSON.stringify({ ...parsed, providers }, null, 2)}\n`;
|
|
178
|
+
await fs.mkdir(this.agentDir, { recursive: true });
|
|
179
|
+
await atomicWrite(path, candidate, 0o600);
|
|
180
|
+
try {
|
|
181
|
+
await verify?.();
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// Decided outside the restore try: a concurrent edit is not a failed
|
|
185
|
+
// restore, and must not be reported as one.
|
|
186
|
+
if (await readOptional(path) !== candidate) {
|
|
187
|
+
throw new Error(`${path} changed while provider setup was being validated`, { cause: err });
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
if (existed)
|
|
191
|
+
await atomicWrite(path, raw, 0o600);
|
|
192
|
+
else
|
|
193
|
+
await fs.unlink(path);
|
|
194
|
+
}
|
|
195
|
+
catch (rollback) {
|
|
196
|
+
throw new AggregateError([err, rollback], `failed to restore ${path}`);
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
#withWrite(write) {
|
|
203
|
+
const result = this.#writes.then(write);
|
|
204
|
+
this.#writes = result.then(() => undefined, () => undefined);
|
|
205
|
+
return result;
|
|
53
206
|
}
|
|
54
207
|
async listResources(scope) {
|
|
55
208
|
return {
|
|
@@ -91,41 +244,134 @@ async function listDir(root, prefix = "", depth = RESOURCE_DEPTH, linked = false
|
|
|
91
244
|
}
|
|
92
245
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
93
246
|
}
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
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
|
-
*/
|
|
247
|
+
/** models.json may contain legacy keys and literal headers. Only stable
|
|
248
|
+
* masks cross the HTTP seam; malformed content must be repaired on disk. */
|
|
99
249
|
function maskModels(raw) {
|
|
250
|
+
if (!raw.trim())
|
|
251
|
+
return "";
|
|
100
252
|
const parsed = parseModels(raw);
|
|
101
253
|
if (!parsed)
|
|
102
|
-
|
|
103
|
-
for (const
|
|
104
|
-
|
|
105
|
-
|
|
254
|
+
throw new Error("models.json must be valid JSON; repair it on disk");
|
|
255
|
+
for (const provider of Object.values(parsed.providers ?? {})) {
|
|
256
|
+
validateBaseUrl(provider);
|
|
257
|
+
if (provider.apiKey !== undefined && typeof provider.apiKey !== "string") {
|
|
258
|
+
throw new Error("models.json apiKey must be a string");
|
|
259
|
+
}
|
|
260
|
+
if (provider.apiKey)
|
|
261
|
+
provider.apiKey = maskKey(provider.apiKey);
|
|
262
|
+
maskHeaders(provider);
|
|
263
|
+
for (const model of modelRecords(provider)) {
|
|
264
|
+
validateBaseUrl(model);
|
|
265
|
+
maskHeaders(model);
|
|
266
|
+
}
|
|
267
|
+
for (const override of overrideRecords(provider))
|
|
268
|
+
maskHeaders(override);
|
|
106
269
|
}
|
|
107
270
|
return JSON.stringify(parsed, null, 2);
|
|
108
271
|
}
|
|
109
|
-
/** Restore stored
|
|
272
|
+
/** Restore stored secrets wherever the incoming value is still its mask. */
|
|
110
273
|
function unmaskModels(content, currentRaw) {
|
|
111
274
|
const incoming = parseModels(content);
|
|
112
275
|
if (!incoming)
|
|
113
276
|
throw new Error("models.json must be valid JSON");
|
|
114
277
|
const current = parseModels(currentRaw);
|
|
115
|
-
for (const [name,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
278
|
+
for (const [name, provider] of Object.entries(incoming.providers ?? {})) {
|
|
279
|
+
validateBaseUrl(provider);
|
|
280
|
+
const stored = current?.providers?.[name];
|
|
281
|
+
if (provider.apiKey !== undefined) {
|
|
282
|
+
if (typeof provider.apiKey !== "string" || typeof stored?.apiKey !== "string" ||
|
|
283
|
+
provider.apiKey !== maskKey(stored.apiKey)) {
|
|
284
|
+
throw new Error("API keys must be configured under Providers");
|
|
285
|
+
}
|
|
286
|
+
provider.apiKey = stored.apiKey;
|
|
287
|
+
}
|
|
288
|
+
restoreHeaders(provider, stored);
|
|
289
|
+
const storedModels = new Map(modelRecords(stored).flatMap((model) => typeof model.id === "string" ? [[model.id, model]] : []));
|
|
290
|
+
for (const model of modelRecords(provider)) {
|
|
291
|
+
validateBaseUrl(model);
|
|
292
|
+
restoreHeaders(model, typeof model.id === "string" ? storedModels.get(model.id) : undefined);
|
|
293
|
+
}
|
|
294
|
+
const storedOverrides = asRecord(stored?.modelOverrides);
|
|
295
|
+
for (const [id, override] of overrideRecords(provider, true)) {
|
|
296
|
+
restoreHeaders(override, asRecord(storedOverrides?.[id]));
|
|
121
297
|
}
|
|
122
298
|
}
|
|
123
299
|
return JSON.stringify(incoming, null, 2);
|
|
124
300
|
}
|
|
301
|
+
const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
302
|
+
? value
|
|
303
|
+
: undefined;
|
|
304
|
+
function modelRecords(provider) {
|
|
305
|
+
if (provider?.models === undefined)
|
|
306
|
+
return [];
|
|
307
|
+
if (!Array.isArray(provider.models))
|
|
308
|
+
throw new Error("models.json models must be an array");
|
|
309
|
+
return provider.models.map((model) => {
|
|
310
|
+
const record = asRecord(model);
|
|
311
|
+
if (!record)
|
|
312
|
+
throw new Error("models.json models must contain objects");
|
|
313
|
+
return record;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function overrideRecords(provider, entries = false) {
|
|
317
|
+
if (provider.modelOverrides === undefined)
|
|
318
|
+
return [];
|
|
319
|
+
const overrides = asRecord(provider.modelOverrides);
|
|
320
|
+
if (!overrides)
|
|
321
|
+
throw new Error("models.json modelOverrides must be an object");
|
|
322
|
+
const records = Object.entries(overrides).map(([id, value]) => {
|
|
323
|
+
const record = asRecord(value);
|
|
324
|
+
if (!record)
|
|
325
|
+
throw new Error("models.json modelOverrides must contain objects");
|
|
326
|
+
return [id, record];
|
|
327
|
+
});
|
|
328
|
+
return entries ? records : records.map(([, record]) => record);
|
|
329
|
+
}
|
|
330
|
+
function validateBaseUrl(owner) {
|
|
331
|
+
if (owner.baseUrl === undefined)
|
|
332
|
+
return;
|
|
333
|
+
if (typeof owner.baseUrl !== "string")
|
|
334
|
+
throw new Error("models.json baseUrl must be a string");
|
|
335
|
+
validateEndpoint(owner.baseUrl);
|
|
336
|
+
}
|
|
337
|
+
function maskHeaders(owner) {
|
|
338
|
+
if (owner.headers === undefined)
|
|
339
|
+
return;
|
|
340
|
+
const headers = asRecord(owner.headers);
|
|
341
|
+
if (!headers || Object.values(headers).some((value) => typeof value !== "string")) {
|
|
342
|
+
throw new Error("models.json headers must contain string values");
|
|
343
|
+
}
|
|
344
|
+
for (const [name, value] of Object.entries(headers))
|
|
345
|
+
headers[name] = maskKey(value);
|
|
346
|
+
}
|
|
347
|
+
function restoreHeaders(incoming, current) {
|
|
348
|
+
if (incoming.headers === undefined)
|
|
349
|
+
return;
|
|
350
|
+
const headers = asRecord(incoming.headers);
|
|
351
|
+
const stored = asRecord(current?.headers);
|
|
352
|
+
if (!headers || !stored)
|
|
353
|
+
throw new Error("Header values must be configured on disk");
|
|
354
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
355
|
+
const original = stored[name];
|
|
356
|
+
if (typeof value !== "string" || typeof original !== "string" || value !== maskKey(original)) {
|
|
357
|
+
throw new Error("Header values must be configured on disk");
|
|
358
|
+
}
|
|
359
|
+
headers[name] = original;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
125
362
|
function parseModels(raw) {
|
|
126
363
|
try {
|
|
127
364
|
const parsed = JSON.parse(raw);
|
|
128
|
-
|
|
365
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
366
|
+
return null;
|
|
367
|
+
const providers = parsed.providers;
|
|
368
|
+
if (providers !== undefined) {
|
|
369
|
+
if (typeof providers !== "object" || providers === null || Array.isArray(providers))
|
|
370
|
+
return null;
|
|
371
|
+
if (Object.values(providers).some((provider) => typeof provider !== "object" || provider === null || Array.isArray(provider)))
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
return parsed;
|
|
129
375
|
}
|
|
130
376
|
catch {
|
|
131
377
|
return null;
|
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
// sweep below leave models.json purely structural — and safely syncable.
|
|
9
9
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
12
|
import { logger } from "../log.js";
|
|
12
13
|
import { defaultAgentDir } from "./config.js";
|
|
13
14
|
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
15
|
export class CredentialStore {
|
|
18
16
|
db;
|
|
19
17
|
secrets;
|
|
@@ -55,19 +53,25 @@ export class CredentialStore {
|
|
|
55
53
|
return next;
|
|
56
54
|
});
|
|
57
55
|
}
|
|
56
|
+
async replaceIfCurrent(providerId, current, replacement) {
|
|
57
|
+
return this.#serialized(undefined, async () => {
|
|
58
|
+
if (!isDeepStrictEqual(this.#get(providerId), current))
|
|
59
|
+
return false;
|
|
60
|
+
if (replacement)
|
|
61
|
+
this.#put(providerId, replacement);
|
|
62
|
+
else
|
|
63
|
+
this.db.prepare("DELETE FROM credentials WHERE key = ?").run(providerId);
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
58
67
|
async delete(providerId, options) {
|
|
59
68
|
return this.#serialized(options, async () => {
|
|
60
69
|
this.db.prepare("DELETE FROM credentials WHERE key = ?").run(providerId);
|
|
61
70
|
});
|
|
62
71
|
}
|
|
63
72
|
#serialized(options, run) {
|
|
64
|
-
const op = this.#chain.then(() => {
|
|
65
|
-
options?.signal?.throwIfAborted();
|
|
66
|
-
this.#ensureImported();
|
|
67
|
-
return run();
|
|
68
|
-
},
|
|
69
73
|
// The chain only sequences; a predecessor's failure is its caller's news.
|
|
70
|
-
() => {
|
|
74
|
+
const op = this.#chain.catch(() => { }).then(() => {
|
|
71
75
|
options?.signal?.throwIfAborted();
|
|
72
76
|
this.#ensureImported();
|
|
73
77
|
return run();
|
|
@@ -80,8 +84,8 @@ export class CredentialStore {
|
|
|
80
84
|
return row ? this.#parse(row.value) : undefined;
|
|
81
85
|
}
|
|
82
86
|
#parse(value) {
|
|
83
|
-
|
|
84
|
-
return JSON.parse(
|
|
87
|
+
// Every row was sealed by #put — the table has never held plaintext.
|
|
88
|
+
return JSON.parse(this.secrets.decrypt(value));
|
|
85
89
|
}
|
|
86
90
|
#put(providerId, credential) {
|
|
87
91
|
this.db
|
|
@@ -169,7 +173,9 @@ export class CredentialStore {
|
|
|
169
173
|
if (moved.length === 0 && shadowed.length === 0)
|
|
170
174
|
return;
|
|
171
175
|
writeFileSync(`${path}.imported`, raw, { mode: 0o600 });
|
|
172
|
-
|
|
176
|
+
// Atomic swap: a crash mid-write must not truncate the file Pi reads.
|
|
177
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
178
|
+
renameSync(`${path}.tmp`, path);
|
|
173
179
|
if (moved.length)
|
|
174
180
|
log.info(`moved ${moved.length} literal API key(s) from models.json into pier.db: ${moved.join(", ")}`);
|
|
175
181
|
if (shadowed.length)
|
package/dist/agent/events.js
CHANGED
|
@@ -12,17 +12,6 @@ export function textOf(content) {
|
|
|
12
12
|
.map((p) => p.text)
|
|
13
13
|
.join("");
|
|
14
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
15
|
function systemOrigin(message) {
|
|
27
16
|
if (message.role !== "custom" || message.customType !== "pier.system-input")
|
|
28
17
|
return null;
|
|
@@ -44,23 +33,6 @@ function systemOrigin(message) {
|
|
|
44
33
|
return origin;
|
|
45
34
|
return null;
|
|
46
35
|
}
|
|
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
36
|
function lastAssistant(messages) {
|
|
65
37
|
if (!messages)
|
|
66
38
|
return undefined;
|
|
@@ -113,8 +85,7 @@ export function toChatTurns(messages) {
|
|
|
113
85
|
const turns = [];
|
|
114
86
|
let steps = []; // activity seen since the last emitted turn
|
|
115
87
|
const pendingTools = new Map();
|
|
116
|
-
|
|
117
|
-
const flush = (role, text, meta, origin, images) => {
|
|
88
|
+
const flush = (role, text, meta, origin) => {
|
|
118
89
|
const turn = { role, text };
|
|
119
90
|
if (meta)
|
|
120
91
|
turn.meta = meta;
|
|
@@ -124,8 +95,6 @@ export function toChatTurns(messages) {
|
|
|
124
95
|
turn.steps = steps;
|
|
125
96
|
steps = [];
|
|
126
97
|
}
|
|
127
|
-
if (images?.length)
|
|
128
|
-
turn.images = images;
|
|
129
98
|
turns.push(turn);
|
|
130
99
|
};
|
|
131
100
|
for (const [i, m] of messages.entries()) {
|
|
@@ -140,7 +109,7 @@ export function toChatTurns(messages) {
|
|
|
140
109
|
}
|
|
141
110
|
const origin = systemOrigin(m);
|
|
142
111
|
if (origin) {
|
|
143
|
-
const text =
|
|
112
|
+
const text = textOf(m.content);
|
|
144
113
|
if (text)
|
|
145
114
|
flush("system", text, undefined, origin);
|
|
146
115
|
continue;
|
|
@@ -165,16 +134,11 @@ export function toChatTurns(messages) {
|
|
|
165
134
|
}
|
|
166
135
|
}
|
|
167
136
|
}
|
|
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
137
|
const text = textOf(m.content);
|
|
174
138
|
// step-only assistant messages keep buffering activity
|
|
175
|
-
if (!text
|
|
139
|
+
if (!text)
|
|
176
140
|
continue;
|
|
177
|
-
flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined
|
|
141
|
+
flush(m.role, text, m.role === "assistant" ? turnMetaAt(messages, i) : undefined);
|
|
178
142
|
}
|
|
179
143
|
// Activity with no answer after it (aborted run) still belongs on the page.
|
|
180
144
|
if (steps.length)
|
|
@@ -204,7 +168,7 @@ export function toSessionEvents(e) {
|
|
|
204
168
|
if (!m)
|
|
205
169
|
return [];
|
|
206
170
|
const origin = systemOrigin(m);
|
|
207
|
-
const text =
|
|
171
|
+
const text = textOf(m.content);
|
|
208
172
|
if (origin)
|
|
209
173
|
return text ? [{ type: "system-input", text, origin }] : [];
|
|
210
174
|
if (m.role !== "user")
|
package/dist/agent/models.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
// Pure model-list curation, unit-testable without Pi. The raw registry
|
|
2
2
|
// returns every catalog entry per authed provider (legacy generations, dated
|
|
3
3
|
// aliases, -latest aliases); the picker wants the common working set.
|
|
4
|
+
/** Operator-pinned models first, menu order, without inventing entries: a
|
|
5
|
+
* pin that is not in the list (stale menu, logged-out provider) is skipped
|
|
6
|
+
* rather than offered as a model that would only fail on selection. */
|
|
7
|
+
export function pinFirst(models, pins) {
|
|
8
|
+
if (!pins.length)
|
|
9
|
+
return models;
|
|
10
|
+
const key = (m) => `${m.provider}/${m.id}`;
|
|
11
|
+
const listed = new Map(models.map((m) => [key(m), m]));
|
|
12
|
+
const pinned = pins.map((p) => listed.get(key(p))).filter((m) => !!m);
|
|
13
|
+
const picked = new Set(pinned.map(key));
|
|
14
|
+
return [...pinned, ...models.filter((m) => !picked.has(key(m)))];
|
|
15
|
+
}
|
|
4
16
|
export function curateModels(models) {
|
|
5
17
|
const ids = new Set(models.map((m) => `${m.provider}/${m.id}`));
|
|
6
18
|
return models
|