@timqi/pier 0.1.2 → 0.1.3

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 (72) hide show
  1. package/dist/agent/config.js +57 -59
  2. package/dist/agent/listing.js +38 -15
  3. package/dist/agent/packages.js +353 -0
  4. package/dist/agent/pi.js +28 -13
  5. package/dist/core/types.js +7 -0
  6. package/dist/extensions/index.js +1 -20
  7. package/dist/main.js +19 -14
  8. package/dist/settings.js +5 -0
  9. package/dist/tools.js +46 -85
  10. package/dist/web/config.js +25 -11
  11. package/dist/web/explorer.js +5 -3
  12. package/dist/web/instance.js +6 -18
  13. package/dist/web/packages.js +84 -0
  14. package/dist/web/public/assets/{activity-DOr8dWeX.js → activity-BSMeRcN2.js} +2 -2
  15. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  16. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  17. package/dist/web/public/assets/boards-BCWQMZry.js +1 -0
  18. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  19. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  20. package/dist/web/public/assets/explorer-Cr4XTi4j.js +4 -0
  21. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  22. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  23. package/dist/web/public/assets/index-C7tA0Ufu.js +85 -0
  24. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  25. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  26. package/dist/web/public/assets/index-DVIt5Gio.css +2 -0
  27. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  28. package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
  29. package/dist/web/public/assets/runs-DdERzeac.js +1 -0
  30. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  31. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  32. package/dist/web/public/assets/settings-CQDAHoMM.js +5 -0
  33. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  34. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  35. package/dist/web/public/assets/task-runs-C-dGUDsH.js +3 -0
  36. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  37. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  38. package/dist/web/public/assets/tasks-CY30H1u1.js +4 -0
  39. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  40. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
  41. package/dist/web/public/index.html +2 -2
  42. package/dist/web/public/index.html.br +0 -0
  43. package/dist/web/public/index.html.gz +0 -0
  44. package/dist/web/server.js +3 -1
  45. package/docs/deploy.md +11 -0
  46. package/package.json +1 -1
  47. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  48. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  49. package/dist/web/public/assets/boards-CneyR23E.js +0 -1
  50. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  51. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  52. package/dist/web/public/assets/explorer-D0srXT1c.js +0 -4
  53. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  54. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  55. package/dist/web/public/assets/index-DbFu15NN.js +0 -85
  56. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  57. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  58. package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
  59. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  60. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  61. package/dist/web/public/assets/runs-Cv0A-e08.js +0 -1
  62. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  63. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  64. package/dist/web/public/assets/settings-VmCjhGBd.js +0 -5
  65. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  66. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  67. package/dist/web/public/assets/task-runs-CqpTV644.js +0 -3
  68. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  69. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  70. package/dist/web/public/assets/tasks-BffPVgXg.js +0 -4
  71. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  72. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
@@ -3,16 +3,21 @@
3
3
  // project) but not the Pi SDK — pure filesystem, unit-testable in a tmp dir.
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { promises as fs } from "node:fs";
6
- import { join, resolve, sep } from "node:path";
7
- import { isProviderApi, validateEndpoint, validateProviderSetup } from "../core/types.js";
6
+ import { join } from "node:path";
7
+ import { isProviderApi, isThinkingLevel, validateEndpoint, validateProviderSetup } from "../core/types.js";
8
8
  import { pierPath } from "../paths.js";
9
9
  import { mergeSnapshotProviders, normalizeAgentSnapshot, snapshotProviders } from "./config-sync.js";
10
10
  const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
11
11
  const PROJECT_FILES = ["AGENTS.md"];
12
- // settings.json is on the list for two fields: the default model and its
13
- // reasoning effort. Everything else in it is machine-local.
12
+ // settings.json is Pier's to write: the default model and its reasoning effort
13
+ // go through writeDefaults, and are the only fields a snapshot carries. The
14
+ // Console shows the file and never edits it; the rest of it is machine-local.
15
+ const READONLY_FILES = ["settings.json"];
14
16
  const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
15
- const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
17
+ /** What a first boot writes (docs/deploy.md says why each key): the list the
18
+ * Console installs into, and Pi's attribution headers off — a server is not a
19
+ * person to survey. Every other key stays Pi's default by omission. */
20
+ const SEED_SETTINGS = { packages: [], enableInstallTelemetry: false };
16
21
  /** Pier owns the Pi runtime dir; main.ts exports it as PI_CODING_AGENT_DIR. */
17
22
  export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
18
23
  /** Stable mask: mapped back by field, without exposing key fragments. */
@@ -94,15 +99,11 @@ export class PiConfigStore {
94
99
  }
95
100
  return scope.kind === "global" ? join(this.agentDir, name) : join(scope.cwd, name);
96
101
  }
97
- resourceRoot(scope, kind) {
98
- return scope.kind === "global"
99
- ? join(this.agentDir, kind)
100
- : join(scope.cwd, ".pi", kind);
101
- }
102
102
  async listFiles(scope) {
103
103
  return Promise.all(this.fileNames(scope).map(async (name) => ({
104
104
  name,
105
105
  exists: await pathExists(this.filePath(scope, name)),
106
+ readonly: READONLY_FILES.includes(name),
106
107
  })));
107
108
  }
108
109
  async readFile(scope, name) {
@@ -115,7 +116,10 @@ export class PiConfigStore {
115
116
  }
116
117
  async writeFile(scope, name, content, expected) {
117
118
  const path = this.filePath(scope, name);
118
- return this.#withWrite(async () => {
119
+ if (READONLY_FILES.includes(name)) {
120
+ throw new Error(`${name} is written by Pier; edit it on disk, then run pier reload`);
121
+ }
122
+ return this.withWrite(async () => {
119
123
  const current = await readOptional(path);
120
124
  const visible = name === "models.json" ? maskModels(current) : current;
121
125
  if (expected !== undefined && visible !== expected)
@@ -160,7 +164,7 @@ export class PiConfigStore {
160
164
  }
161
165
  async setupProvider(input, verify) {
162
166
  validateProviderSetup(input);
163
- await this.#withWrite(async () => {
167
+ await this.withWrite(async () => {
164
168
  const path = join(this.agentDir, "models.json");
165
169
  const raw = await readNullable(path);
166
170
  const parsed = raw?.trim() ? parseModels(raw) : {};
@@ -229,26 +233,49 @@ export class PiConfigStore {
229
233
  }
230
234
  });
231
235
  }
236
+ async readDefaults() {
237
+ await this.#writes;
238
+ this.#assertSnapshot();
239
+ return settingsDefaults(readSettings(await readNullable(join(this.agentDir, "settings.json"))));
240
+ }
241
+ writeDefaults(defaults) {
242
+ return this.withWrite(async () => {
243
+ const path = join(this.agentDir, "settings.json");
244
+ const raw = await readNullable(path);
245
+ const next = withDefaults(raw, defaults);
246
+ if (typeof next !== "string" || next === raw)
247
+ return;
248
+ await fs.mkdir(this.agentDir, { recursive: true });
249
+ await atomicWrite(path, next);
250
+ });
251
+ }
252
+ /** Once per boot, before a session opens: SEED_SETTINGS when there is no
253
+ * settings.json at all. An existing file, whatever it holds, is never touched. */
254
+ seedSettings() {
255
+ return this.withWrite(async () => {
256
+ const path = join(this.agentDir, "settings.json");
257
+ if (await pathExists(path))
258
+ return;
259
+ await fs.mkdir(this.agentDir, { recursive: true });
260
+ await atomicWrite(path, `${JSON.stringify(SEED_SETTINGS, null, 2)}\n`);
261
+ });
262
+ }
232
263
  exportSnapshot() {
233
- return this.#withWrite(async () => {
264
+ return this.withWrite(async () => {
234
265
  const [system, agents, raw, rawSettings] = await Promise.all(SNAPSHOT_FILES.map((name) => readNullable(join(this.agentDir, name))));
235
266
  const parsed = raw?.trim() ? parseModels(raw) : {};
236
267
  if (!parsed)
237
268
  throw new Error("models.json must be valid JSON before exporting configuration");
238
- const settings = readSettings(rawSettings);
239
269
  return normalizeAgentSnapshot({
240
270
  files: { "SYSTEM.md": system, "AGENTS.md": agents },
241
271
  providers: snapshotProviders(parsed.providers),
242
- defaultModel: defaultModelRef(settings),
243
- // Unlike the pair, a lone level needs no reading of its own; the
244
- // snapshot boundary is the one place that says which ones exist.
245
- defaultThinkingLevel: settings.defaultThinkingLevel ?? null,
272
+ ...settingsDefaults(readSettings(rawSettings)),
246
273
  });
247
274
  });
248
275
  }
249
276
  async applySnapshot(snapshot, commit) {
250
277
  const incoming = normalizeAgentSnapshot(snapshot);
251
- await this.#withWrite(async () => {
278
+ await this.withWrite(async () => {
252
279
  const names = SNAPSHOT_FILES;
253
280
  const before = await Promise.all(names.map((name) => readNullable(join(this.agentDir, name))));
254
281
  const raw = before[2];
@@ -312,50 +339,14 @@ export class PiConfigStore {
312
339
  }
313
340
  });
314
341
  }
315
- /** Pi loads several files directly; hold the same queue while it opens a session. */
316
- withSnapshot(read) {
317
- return this.#withWrite(read);
318
- }
319
- #withWrite(write) {
342
+ /** The one queue every writer of this directory holds: settings.json has two
343
+ * writers (writeDefaults and Pi's SettingsManager in agent/packages.ts),
344
+ * and Pi reads several files directly when it opens a session. */
345
+ withWrite(write) {
320
346
  const result = this.#writes.then(() => { this.#assertSnapshot(); return write(); });
321
347
  this.#writes = result.then(() => undefined, () => undefined);
322
348
  return result;
323
349
  }
324
- async listResources(scope) {
325
- return {
326
- extensions: await listDir(this.resourceRoot(scope, "extensions")),
327
- skills: await listDir(this.resourceRoot(scope, "skills")),
328
- };
329
- }
330
- async readResource(scope, kind, name) {
331
- const root = this.resourceRoot(scope, kind);
332
- const path = resolve(root, name);
333
- // Containment check — the listing is relative paths, reject anything else.
334
- if (!path.startsWith(root + sep))
335
- throw new Error(`invalid resource path: ${name}`);
336
- return fs.readFile(path, "utf8");
337
- }
338
- }
339
- /** Symlinks are followed (skills are routinely linked in from elsewhere) and
340
- * flagged; the depth bound is also the cycle guard. */
341
- async function listDir(root, prefix = "", depth = RESOURCE_DEPTH, linked = false) {
342
- if (depth === 0)
343
- return [];
344
- const entries = await fs.readdir(join(root, prefix), { withFileTypes: true }).catch(() => []);
345
- const out = [];
346
- for (const e of entries) {
347
- const rel = prefix ? `${prefix}/${e.name}` : e.name;
348
- const link = linked || e.isSymbolicLink();
349
- // A Dirent for a symlink is neither file nor directory — stat through it.
350
- const target = e.isSymbolicLink()
351
- ? await fs.stat(join(root, rel)).catch(() => null) // dangling link → skip
352
- : e;
353
- if (target?.isDirectory())
354
- out.push(...(await listDir(root, rel, depth - 1, link)));
355
- else if (target?.isFile())
356
- out.push({ name: rel, link });
357
- }
358
- return out.sort((a, b) => a.name.localeCompare(b.name));
359
350
  }
360
351
  /** models.json may contain legacy keys and literal headers. Only stable
361
352
  * masks cross the HTTP seam; malformed content must be repaired on disk. */
@@ -495,6 +486,13 @@ function defaultModelRef(settings) {
495
486
  }
496
487
  return { provider, id };
497
488
  }
489
+ function settingsDefaults(settings) {
490
+ const level = settings.defaultThinkingLevel;
491
+ if (level !== undefined && !isThinkingLevel(level)) {
492
+ throw new Error("settings.json default reasoning effort must be a level Pi accepts");
493
+ }
494
+ return { defaultModel: defaultModelRef(settings), defaultThinkingLevel: level ?? null };
495
+ }
498
496
  /** The incoming defaults replace their own fields and nothing else; a field
499
497
  * the source never states (an older one) leaves the local settings.json
500
498
  * alone, and `null` is a stated "no default" that clears it. Unchanged
@@ -76,16 +76,34 @@ const SEARCH_LIMIT = 8;
76
76
  /** A trigram token is one character, so `snippet()` is asked for its ceiling
77
77
  * of 64 tokens; the default 12 fits one word. */
78
78
  const SNIPPET_CHARS = 64;
79
+ /** Whitespace splits a query: a space is "and this too", not a character to
80
+ * find. A term is quoted where it has to be, so a phrase is still findable. */
81
+ const termsOf = (query) => query.split(/\s+/).filter(Boolean);
79
82
  /** Delimited like `snippet()` (\u0001 … \u0002, `…` for a cut), so a surface
80
- * draws one shape for both paths. ASCII case folding: LIKE found the row by
81
- * the same rule. */
82
- function around(text, query) {
83
- const hit = text.toLowerCase().indexOf(query.toLowerCase());
84
- if (hit < 0)
83
+ * draws one shape for both paths. Every term is marked where it first
84
+ * appears inside the window, as `snippet()` marks every phrase it kept.
85
+ * ASCII case folding: LIKE found the row by the same rule. */
86
+ function around(text, terms) {
87
+ const lower = text.toLowerCase();
88
+ const found = terms
89
+ .map((term) => ({ at: lower.indexOf(term.toLowerCase()), length: term.length }))
90
+ .filter((mark) => mark.at >= 0)
91
+ .sort((a, b) => a.at - b.at);
92
+ const first = found[0];
93
+ if (!first)
85
94
  return text.slice(0, SNIPPET_CHARS);
86
- const start = Math.max(0, hit - SNIPPET_CHARS / 2);
87
- const end = Math.min(text.length, hit + query.length + SNIPPET_CHARS / 2);
88
- return `${start ? "…" : ""}${text.slice(start, hit)}\u0001${text.slice(hit, hit + query.length)}\u0002${text.slice(hit + query.length, end)}${end < text.length ? "…" : ""}`;
95
+ const start = Math.max(0, first.at - SNIPPET_CHARS / 2);
96
+ const end = Math.min(text.length, first.at + first.length + SNIPPET_CHARS / 2);
97
+ let out = start ? "…" : "";
98
+ let at = start;
99
+ for (const mark of found) {
100
+ // Terms that overlap one another, or reach past the window, mark once.
101
+ if (mark.at < at || mark.at + mark.length > end)
102
+ continue;
103
+ out += `${text.slice(at, mark.at)}\u0001${text.slice(mark.at, mark.at + mark.length)}\u0002`;
104
+ at = mark.at + mark.length;
105
+ }
106
+ return `${out}${text.slice(at, end)}${end < text.length ? "…" : ""}`;
89
107
  }
90
108
  export class IndexedListing {
91
109
  dir;
@@ -194,15 +212,20 @@ export class IndexedListing {
194
212
  this.#save([], stale);
195
213
  return stale.length;
196
214
  }
197
- /** Under three code points the trigram tokenizer has nothing to match, and a
198
- * two-character query is what a CJK word often is: substring scan instead. */
215
+ /** Every term must appear, in any order, anywhere in the one message. Under
216
+ * three code points the trigram tokenizer has nothing to match, and a
217
+ * two-character term is what a CJK word often is: substring scan instead —
218
+ * one short term puts the whole query on that path. */
199
219
  search(query, limit = SEARCH_LIMIT) {
200
220
  const sql = this.#sql();
201
- const rows = [...query].length >= 3
202
- // Quoted: the query is a string to find, never FTS syntax.
221
+ const terms = termsOf(query);
222
+ if (!terms.length)
223
+ return [];
224
+ const rows = terms.every((term) => [...term].length >= 3)
225
+ // Quoted: a term is a string to find, never FTS syntax.
203
226
  ? sql(`SELECT session_id, role, at, snippet(session_fts, 0, char(1), char(2), '…', ${SNIPPET_CHARS}) AS snippet
204
- FROM session_fts WHERE text MATCH ? ORDER BY bm25(session_fts), at DESC`).iterate(`"${query.replaceAll('"', '""')}"`)
205
- : sql(`SELECT session_id, role, at, text FROM session_fts WHERE text LIKE ? ESCAPE '\\' ORDER BY at DESC`).iterate(`%${query.replaceAll(/[\\%_]/g, "\\$&")}%`);
227
+ FROM session_fts WHERE text MATCH ? ORDER BY bm25(session_fts), at DESC`).iterate(terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" AND "))
228
+ : sql(`SELECT session_id, role, at, text FROM session_fts WHERE ${terms.map(() => "text LIKE ? ESCAPE '\\'").join(" AND ")} ORDER BY at DESC`).iterate(...terms.map((term) => `%${term.replaceAll(/[\\%_]/g, "\\$&")}%`));
206
229
  const hits = [];
207
230
  const seen = new Set();
208
231
  // Walked, not fetched: a chatty session has hundreds of rows for one hit.
@@ -214,7 +237,7 @@ export class IndexedListing {
214
237
  sessionId: row.session_id,
215
238
  role: row.role,
216
239
  at: row.at,
217
- snippet: row.snippet ?? around(row.text ?? "", query),
240
+ snippet: row.snippet ?? around(row.text ?? "", terms),
218
241
  });
219
242
  if (hits.length >= limit)
220
243
  break;
@@ -0,0 +1,353 @@
1
+ // The package registry behind the PackageStore seam: Pi's DefaultPackageManager
2
+ // and SettingsManager, the second and last SDK-importing file in agent/. pi.ts
3
+ // opens sessions; this file changes what they open with. No Pi type may appear
4
+ // in an exported signature.
5
+ import { existsSync, promises as fs } from "node:fs";
6
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { DefaultPackageManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
8
+ import { PackageError, } from "../core/types.js";
9
+ import { BUNDLED } from "../extensions/index.js";
10
+ import { logger } from "../log.js";
11
+ import { defaultAgentDir } from "./config.js";
12
+ import { shadowedBuiltin } from "./pi.js";
13
+ const log = logger("packages");
14
+ /** Set by how long a released fix may sit unnoticed, like update.ts. */
15
+ const CHECK_EVERY_MS = 24 * 60 * 60_000;
16
+ const KINDS = { extensions: "extension", skills: "skill" };
17
+ /** The state line for a skill whose tool this instance is not given (docs/design/03). */
18
+ const FOLLOWS_TOOL = "follows Channels → agent tool";
19
+ /** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): a `local` file whose
20
+ * switch is the rtk tool's, so a settings.json pattern would fight the tool. */
21
+ const RTK_FILE = join("extensions", "rtk.ts");
22
+ const RTK_STATE = "installed by the rtk tool";
23
+ export const kindOf = (source) => source.startsWith("npm:") ? "npm" : /^(git:|(https?|ssh|git):\/\/)/.test(source) ? "git" : "path";
24
+ /** A trailing `@ref` on an npm or git source; the leading `@` of a scope is not one. */
25
+ export const pinnedRef = (source) => {
26
+ const at = source.lastIndexOf("@");
27
+ const ref = at > 0 ? source.slice(at + 1) : "";
28
+ return ref && !/[/:]/.test(ref) ? ref : null;
29
+ };
30
+ /** An extension is named by its file (or the directory its index sits in); a
31
+ * skill by its directory. Pi reads the frontmatter name at load, not here. */
32
+ const nameOf = (kind, path) => {
33
+ const file = basename(path);
34
+ if (kind === "skill")
35
+ return file === "SKILL.md" ? basename(dirname(path)) : file.replace(/\.md$/, "");
36
+ const stem = file.replace(/\.[cm]?[jt]sx?$/, "");
37
+ return stem === "index" ? basename(dirname(path)) : stem;
38
+ };
39
+ const versionFile = async (dir) => {
40
+ if (!dir)
41
+ return null;
42
+ try {
43
+ const { version } = JSON.parse(await fs.readFile(join(dir, "package.json"), "utf8"));
44
+ return typeof version === "string" ? version : null;
45
+ }
46
+ catch (err) {
47
+ if (err instanceof Error && "code" in err && err.code === "ENOENT")
48
+ return null;
49
+ log.warn(`${dir}/package.json unreadable — no version shown`, err);
50
+ return null;
51
+ }
52
+ };
53
+ /** Pi's own toggle rule (its config selector): one `+path`/`-path` per resource,
54
+ * any earlier pattern for the same path removed first. A bare entry stays: it
55
+ * is what lists the file, not a pattern on it. */
56
+ const flip = (entries, pattern, enabled) => [
57
+ ...entries.filter((p) => !(/^[!+-]/.test(p) && p.slice(1) === pattern)),
58
+ `${enabled ? "+" : "-"}${pattern}`,
59
+ ];
60
+ /** What npm, git or Pi's manifest refused is the upstream's failure, answered
61
+ * as such (502) with Pi's own sentence; a PackageError passes through. */
62
+ const upstream = async (what, run) => {
63
+ try {
64
+ return await run();
65
+ }
66
+ catch (err) {
67
+ if (err instanceof PackageError)
68
+ throw err;
69
+ throw new PackageError("unreachable", `${what} failed: ${err instanceof Error ? err.message : String(err)}`);
70
+ }
71
+ };
72
+ const resourceRow = (kind, r) => ({
73
+ kind, name: nameOf(kind, r.path), path: r.path, enabled: r.enabled, state: null,
74
+ });
75
+ export class PiPackageStore {
76
+ config;
77
+ pier;
78
+ skillDirs;
79
+ agentDir;
80
+ #busy = null;
81
+ #updates = new Set();
82
+ #checkedAt = null;
83
+ constructor(config, pier,
84
+ /** Pier's own skill directories, the ones pi.ts loads per session. */
85
+ skillDirs, agentDir = defaultAgentDir()) {
86
+ this.config = config;
87
+ this.pier = pier;
88
+ this.skillDirs = skillDirs;
89
+ this.agentDir = agentDir;
90
+ }
91
+ /** Per call, never retained: Pi's manager caches nothing Pier wants to keep,
92
+ * and a fresh SettingsManager reads whatever writeDefaults wrote last. */
93
+ #open(cwd) {
94
+ // Untrusted without a cwd: the project scope of the agent dir is nothing.
95
+ const settings = SettingsManager.create(cwd ?? this.agentDir, this.agentDir, { projectTrusted: cwd !== undefined });
96
+ this.#raise(settings, "read");
97
+ const manager = new DefaultPackageManager({ cwd: cwd ?? this.agentDir, agentDir: this.agentDir, settingsManager: settings });
98
+ manager.setProgressCallback((e) => log.info(`${e.action} ${e.source}: ${e.type}${e.message ? ` — ${e.message}` : ""}`));
99
+ return { settings, manager };
100
+ }
101
+ /** Pi's SettingsManager records a failed load or write and carries on (§5). */
102
+ #raise(settings, what) {
103
+ const errors = settings.drainErrors();
104
+ if (!errors.length)
105
+ return;
106
+ const detail = errors.map((e) => `${e.path ?? e.scope}: ${e.error.message}`).join("; ");
107
+ throw new PackageError("invalid", `settings.json could not be ${what}: ${detail}`);
108
+ }
109
+ async list(cwd) {
110
+ const { manager } = this.#open(cwd);
111
+ const rtkPath = join(this.agentDir, RTK_FILE);
112
+ const resolved = await manager.resolve(async () => "skip");
113
+ const rows = new Map();
114
+ const row = (source, kind, scope, installedPath) => {
115
+ const key = `${scope}\0${source}`;
116
+ let found = rows.get(key);
117
+ if (!found) {
118
+ found = { source, kind, scope, version: null, installedPath, updateAvailable: false, resources: [] };
119
+ rows.set(key, found);
120
+ }
121
+ return found;
122
+ };
123
+ row("pier", "pier", "global", null);
124
+ row("local", "local", "global", this.agentDir);
125
+ for (const key of Object.keys(KINDS)) {
126
+ for (const r of resolved[key]) {
127
+ const own = r.metadata.origin === "top-level";
128
+ const scope = r.metadata.scope === "project" ? "project" : "global";
129
+ const resource = resourceRow(KINDS[key], r);
130
+ if (r.path === rtkPath)
131
+ Object.assign(resource, { state: RTK_STATE, locked: true });
132
+ row(own ? "local" : r.metadata.source, own ? "local" : kindOf(r.metadata.source), scope, r.metadata.baseDir ?? null)
133
+ .resources.push(resource);
134
+ }
135
+ }
136
+ // Configured but unresolved (not installed, or empty): still a row.
137
+ for (const p of manager.listConfiguredPackages()) {
138
+ const found = row(p.source, kindOf(p.source), p.scope === "project" ? "project" : "global", p.installedPath ?? null);
139
+ found.installedPath ??= p.installedPath ?? null;
140
+ found.updateAvailable = this.#updates.has(p.source);
141
+ }
142
+ // pier, local, the global packages, then the project's rows; Pi's own order is precedence.
143
+ const rank = (p) => p.kind === "pier" ? 0 : p.scope === "project" ? 3 : p.kind === "local" ? 1 : 2;
144
+ const packages = [...rows.values()].sort((a, b) => rank(a) - rank(b));
145
+ for (const pkg of packages) {
146
+ if (pkg.kind === "pier")
147
+ await this.#fillPier(pkg);
148
+ else if (pkg.kind === "git")
149
+ pkg.version = pinnedRef(pkg.source);
150
+ else if (pkg.kind !== "local")
151
+ pkg.version = await versionFile(pkg.installedPath);
152
+ }
153
+ return { packages, checkedAt: this.#checkedAt, busy: this.#busy };
154
+ }
155
+ async #fillPier(pkg) {
156
+ pkg.version = this.pier.version;
157
+ const { extensions: on, skillsOff: off } = this.pier.settings.get();
158
+ // Off is off: a stand-down recorded while it was on is not this switch's state.
159
+ pkg.resources = BUNDLED.map(({ name }) => ({
160
+ kind: "extension", name, path: `<inline:${name}>`, enabled: on.includes(name),
161
+ state: on.includes(name) ? shadowedBuiltin(name) : null,
162
+ }));
163
+ const gone = new Set(this.pier.tools.filter((t) => t.skill && !(t.available?.() ?? true)).map((t) => t.skill));
164
+ for (const dir of this.skillDirs) {
165
+ for (const entry of (await fs.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory())) {
166
+ const path = join(dir, entry.name, "SKILL.md");
167
+ if (!existsSync(path))
168
+ continue;
169
+ pkg.resources.push({
170
+ kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name),
171
+ state: gone.has(entry.name) ? FOLLOWS_TOOL : null,
172
+ });
173
+ }
174
+ }
175
+ }
176
+ /** One at a time: the second Console click is told, not queued behind an npm install. */
177
+ async #operate(source, run) {
178
+ if (this.#busy)
179
+ throw new PackageError("busy", `${this.#busy} is being changed; try again when it finishes`);
180
+ this.#busy = source;
181
+ try {
182
+ return await this.#write(run);
183
+ }
184
+ finally {
185
+ this.#busy = null;
186
+ }
187
+ }
188
+ /** Inside ConfigStore's queue, so writeDefaults and a session open never
189
+ * interleave with Pi's merge-write; flushed and checked before it ends. */
190
+ #write(run, cwd) {
191
+ return this.config.withWrite(async () => {
192
+ const { manager, settings } = this.#open(cwd);
193
+ const result = await run(manager, settings);
194
+ await settings.flush();
195
+ this.#raise(settings, "written");
196
+ return result;
197
+ });
198
+ }
199
+ async #row(source) {
200
+ const found = (await this.list()).packages.find((p) => p.source === source);
201
+ if (!found)
202
+ throw new PackageError("missing", `${source} is not a configured package`);
203
+ return found;
204
+ }
205
+ /** `local` is one row per scope: the resource, not the source, picks the row. */
206
+ async #find({ source, kind, path, cwd }) {
207
+ const owners = (await this.list(cwd)).packages.filter((p) => p.source === source);
208
+ if (!owners.length)
209
+ throw new PackageError("missing", `${source} is not a configured package`);
210
+ for (const pkg of owners) {
211
+ const resource = pkg.resources.find((r) => r.kind === kind && r.path === path);
212
+ if (resource)
213
+ return { pkg, resource };
214
+ }
215
+ throw new PackageError("missing", `${source} has no ${kind} at ${path}`);
216
+ }
217
+ #builtin(source) {
218
+ if (source === "pier" || source === "local")
219
+ throw new PackageError("refused", `${source} is built in`);
220
+ }
221
+ async install(source) {
222
+ const spec = source.trim();
223
+ if (!spec)
224
+ throw new PackageError("invalid", "a package source is required");
225
+ this.#builtin(spec);
226
+ if (kindOf(spec) === "path" && !existsSync(resolve(this.agentDir, spec))) {
227
+ throw new PackageError("invalid", `no such path: ${resolve(this.agentDir, spec)}`);
228
+ }
229
+ // Pi stores a local path relative to the settings file: the row is named
230
+ // by what landed in settings.json, not by what was typed.
231
+ const stored = await this.#operate(spec, async (manager) => {
232
+ const sources = () => manager.listConfiguredPackages().filter((p) => p.scope === "user").map((p) => p.source);
233
+ const before = new Set(sources());
234
+ await upstream(`installing ${spec}`, () => manager.installAndPersist(spec));
235
+ const added = sources().find((s) => !before.has(s));
236
+ if (!added)
237
+ throw new PackageError("refused", `${spec} is already configured`);
238
+ return added;
239
+ });
240
+ return this.#row(stored);
241
+ }
242
+ async remove(source) {
243
+ this.#builtin(source);
244
+ await this.#operate(source, async (manager) => {
245
+ if (!manager.listConfiguredPackages().some((p) => p.scope === "user" && p.source === source)) {
246
+ throw new PackageError("missing", `${source} is not in settings.json`);
247
+ }
248
+ await upstream(`removing ${source}`, () => manager.removeAndPersist(source));
249
+ });
250
+ }
251
+ async update(source) {
252
+ const configured = this.#open().manager.listConfiguredPackages().filter((p) => p.scope === "user");
253
+ const movable = (s) => kindOf(s) !== "path" && pinnedRef(s) === null;
254
+ if (source !== undefined) {
255
+ this.#builtin(source);
256
+ if (!configured.some((p) => p.source === source))
257
+ throw new PackageError("missing", `${source} is not in settings.json`);
258
+ if (!movable(source))
259
+ throw new PackageError("refused", `${source} is pinned or local — nothing to move`);
260
+ }
261
+ const targets = source === undefined ? configured.map((p) => p.source).filter(movable) : [source];
262
+ await this.#operate(source ?? "every package", (manager) => upstream(`updating ${source ?? "every package"}`, () => manager.update(source)));
263
+ for (const s of targets)
264
+ this.#updates.delete(s);
265
+ const { packages } = await this.list();
266
+ return packages.filter((p) => p.scope === "global" && targets.includes(p.source));
267
+ }
268
+ async setEnabled(change) {
269
+ const { source, kind, path, enabled, cwd } = change;
270
+ const { pkg, resource } = await this.#find(change);
271
+ if (resource.locked)
272
+ throw new PackageError("refused", `${resource.name} is ${resource.state} — its switch is under Tools`);
273
+ if (pkg.kind === "pier") {
274
+ const { extensions, skillsOff } = this.pier.settings.get();
275
+ if (kind === "skill")
276
+ this.pier.settings.setSkillsOff(withName(skillsOff, resource.name, !enabled));
277
+ else
278
+ this.pier.settings.setExtensions(withName(extensions, resource.name, enabled));
279
+ }
280
+ else {
281
+ const key = kind === "extension" ? "extensions" : "skills";
282
+ await this.#write(async (manager, settings) => {
283
+ // Re-resolved under the lock: the pattern is relative to the base Pi
284
+ // resolved it from, which is the only base its filter will match.
285
+ const r = (await manager.resolve(async () => "skip"))[key].find((it) => it.path === path);
286
+ if (!r)
287
+ throw new PackageError("missing", `${path} is not loaded from ${source}`);
288
+ const project = cwd !== undefined;
289
+ const inherited = project && r.metadata.scope !== "project";
290
+ const scoped = project ? settings.getProjectSettings() : settings.getGlobalSettings();
291
+ const projectDir = join(cwd ?? this.agentDir, ".pi");
292
+ if (r.metadata.origin === "top-level") {
293
+ // A global resource switched for one project is named by its absolute path there.
294
+ const pattern = inherited ? path : relative(r.metadata.baseDir ?? (project ? projectDir : this.agentDir), path);
295
+ const next = flip(scoped[key] ?? [], pattern, enabled);
296
+ if (inherited && !next.includes(path))
297
+ next.unshift(path);
298
+ if (project)
299
+ settings[key === "extensions" ? "setProjectExtensionPaths" : "setProjectSkillPaths"](next);
300
+ else
301
+ settings[key === "extensions" ? "setExtensionPaths" : "setSkillPaths"](next);
302
+ }
303
+ else {
304
+ const pattern = relative(r.metadata.baseDir ?? dirname(path), path);
305
+ const packages = [...(scoped.packages ?? [])];
306
+ let at = packages.findIndex((p) => (typeof p === "string" ? p : p.source) === source);
307
+ // A global package overridden for one project: Pi's delta entry, a
308
+ // local path re-based on .pi/ because that is where Pi resolves it from.
309
+ if (at === -1 && inherited) {
310
+ const own = kindOf(source) === "path" ? relative(projectDir, resolve(this.agentDir, source)) || "." : source;
311
+ at = packages.push({ source: own, autoload: false }) - 1;
312
+ }
313
+ if (at === -1)
314
+ throw new PackageError("missing", `${source} is not in settings.json`);
315
+ const entry = packages[at];
316
+ const filtered = typeof entry === "string" ? { source: entry } : { ...entry };
317
+ filtered[key] = flip(filtered[key] ?? [], pattern, enabled);
318
+ packages[at] = filtered;
319
+ if (project)
320
+ settings.setProjectPackages(packages);
321
+ else
322
+ settings.setPackages(packages);
323
+ }
324
+ }, cwd);
325
+ }
326
+ // By path, not source: an override moves the resource to the project's row.
327
+ const after = (await this.list(cwd)).packages.flatMap((p) => p.resources).find((r) => r.kind === kind && r.path === path);
328
+ if (!after)
329
+ throw new PackageError("missing", `${path} is no longer loaded`);
330
+ return after;
331
+ }
332
+ async checkUpdates() {
333
+ const { manager } = this.#open();
334
+ const found = await upstream("update check", () => manager.checkForAvailableUpdates());
335
+ this.#updates = new Set(found.filter((u) => u.scope === "user").map((u) => u.source));
336
+ this.#checkedAt = new Date().toISOString();
337
+ if (this.#updates.size)
338
+ log.info(`package updates available: ${[...this.#updates].join(", ")}`);
339
+ return this.list();
340
+ }
341
+ /** At boot and daily, like update.ts; the answer waits in `list` for the next
342
+ * Console open. Returns its own stop. */
343
+ watchUpdates(everyMs = CHECK_EVERY_MS) {
344
+ const tick = () => {
345
+ void this.checkUpdates().catch((err) => log.warn(String(err)));
346
+ };
347
+ tick();
348
+ const timer = setInterval(tick, everyMs);
349
+ timer.unref();
350
+ return () => clearInterval(timer);
351
+ }
352
+ }
353
+ const withName = (names, name, on) => on ? [...new Set([...names, name])] : names.filter((n) => n !== name);