@timqi/pier 0.1.2 → 0.1.4

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 (113) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +63 -59
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/listing.js +38 -15
  5. package/dist/agent/packages.js +347 -0
  6. package/dist/agent/pi.js +33 -59
  7. package/dist/channels/config.js +30 -20
  8. package/dist/channels/routes.js +0 -3
  9. package/dist/channels/slack-api.js +19 -10
  10. package/dist/channels/slack-cli.js +503 -0
  11. package/dist/channels/slack-directory.js +2 -2
  12. package/dist/channels/slack-outbound.js +1 -1
  13. package/dist/channels/slack-thread.js +41 -0
  14. package/dist/channels/slack-transcript.js +107 -0
  15. package/dist/channels/slack.js +9 -17
  16. package/dist/channels/types.js +0 -1
  17. package/dist/cli.js +158 -7
  18. package/dist/core/identity.js +20 -11
  19. package/dist/core/reply.js +16 -15
  20. package/dist/core/router.js +4 -1
  21. package/dist/core/types.js +7 -0
  22. package/dist/db.js +28 -0
  23. package/dist/extensions/index.js +1 -20
  24. package/dist/main.js +34 -42
  25. package/dist/paths.js +3 -0
  26. package/dist/secrets.js +2 -1
  27. package/dist/settings.js +5 -0
  28. package/dist/socket.js +95 -0
  29. package/dist/tasks/agent.js +5 -6
  30. package/dist/tasks/callbacks.js +2 -2
  31. package/dist/tasks/cli.js +215 -0
  32. package/dist/tasks/definitions.js +3 -3
  33. package/dist/tasks/execution.js +1 -3
  34. package/dist/tasks/groups.js +2 -5
  35. package/dist/tasks/messages.js +18 -158
  36. package/dist/tasks/operations.js +323 -0
  37. package/dist/tasks/routes.js +1 -11
  38. package/dist/tasks/runs.js +5 -20
  39. package/dist/tasks/service.js +18 -43
  40. package/dist/tasks/store.js +15 -15
  41. package/dist/tools.js +63 -85
  42. package/dist/vault.js +107 -0
  43. package/dist/web/auth.js +2 -1
  44. package/dist/web/config.js +25 -11
  45. package/dist/web/explorer.js +5 -3
  46. package/dist/web/instance.js +6 -18
  47. package/dist/web/packages.js +84 -0
  48. package/dist/web/public/assets/{activity-DOr8dWeX.js → activity-NLS8W9yl.js} +2 -2
  49. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  50. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  51. package/dist/web/public/assets/boards-DleegoiC.js +1 -0
  52. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  53. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  54. package/dist/web/public/assets/explorer-vJvV1Sx0.js +4 -0
  55. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  56. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  57. package/dist/web/public/assets/index-Bs-gol9o.css +2 -0
  58. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  59. package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
  60. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  61. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  62. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  63. package/dist/web/public/assets/runs-C5FveZ75.js +1 -0
  64. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  65. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  66. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  67. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  68. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  69. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +3 -0
  70. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  71. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  72. package/dist/web/public/assets/tasks-R2Pv0Abg.js +4 -0
  73. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  74. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  75. package/dist/web/public/index.html +2 -2
  76. package/dist/web/public/index.html.br +0 -0
  77. package/dist/web/public/index.html.gz +0 -0
  78. package/dist/web/server.js +3 -1
  79. package/dist/web/vault.js +68 -0
  80. package/docs/deploy.md +42 -8
  81. package/package.json +1 -1
  82. package/skills/pier-help/SKILL.md +10 -6
  83. package/skills/pier-slack/SKILL.md +52 -71
  84. package/skills/pier-tasks/SKILL.md +48 -145
  85. package/skills/pier-vault/SKILL.md +36 -0
  86. package/dist/channels/slack-tool.js +0 -416
  87. package/dist/tasks/tool.js +0 -416
  88. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  89. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  90. package/dist/web/public/assets/boards-CneyR23E.js +0 -1
  91. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  92. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  93. package/dist/web/public/assets/explorer-D0srXT1c.js +0 -4
  94. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  95. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  96. package/dist/web/public/assets/index-DbFu15NN.js +0 -85
  97. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  98. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  99. package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
  100. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  101. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  102. package/dist/web/public/assets/runs-Cv0A-e08.js +0 -1
  103. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  104. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  105. package/dist/web/public/assets/settings-VmCjhGBd.js +0 -5
  106. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  107. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  108. package/dist/web/public/assets/task-runs-CqpTV644.js +0 -3
  109. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  110. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  111. package/dist/web/public/assets/tasks-BffPVgXg.js +0 -4
  112. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  113. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
package/README.md CHANGED
@@ -37,8 +37,8 @@ otherwise).
37
37
  **The first start generates a password and prints it once.** Lost it?
38
38
  `sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart. Open
39
39
  `http://localhost:3141`, sign in; **Console → Settings** configures Models,
40
- Agent, Channels, the public URL, password and master key; **New session** picks
41
- the directory the agent's shell runs in.
40
+ Agent, Channels, Vault, the public URL, password and master key; **New session**
41
+ picks the directory the agent's shell runs in.
42
42
 
43
43
  ## Configure Pi
44
44
 
@@ -67,7 +67,12 @@ Console → Settings:
67
67
  copy of yours registering the same tool makes Pier's stand down) and the
68
68
  managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
69
69
  [ubix](https://github.com/timqi/ubix) block), installed into
70
- `~/.pier/tools/bin`, first on every session's PATH.
70
+ `~/.pier/tools/bin`, first on every session's PATH beside the `pier` shim
71
+ Pier writes there at start.
72
+ - **Vault** — named secrets an agent's commands receive through `pier vault
73
+ run` without the value entering its context (`docs/design/07-vault.md`);
74
+ `pier slack` and `pier task` reach the running Pier the same way
75
+ (`docs/design/08-cli-socket.md`).
71
76
 
72
77
  On first credential access an existing `auth.json` is imported into the sealed
73
78
  store and renamed `auth.json.imported`; literal keys in `models.json` likewise
@@ -3,16 +3,27 @@
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, Pi's attribution headers off — a server is not a
19
+ * person to survey — and a retry budget of minutes, because an unattended
20
+ * instance has nobody to re-ask after Pi's 14-second default gives up. Every
21
+ * other key stays Pi's default by omission. */
22
+ const SEED_SETTINGS = {
23
+ packages: [],
24
+ enableInstallTelemetry: false,
25
+ retry: { maxRetries: 5, baseDelayMs: 5000 },
26
+ };
16
27
  /** Pier owns the Pi runtime dir; main.ts exports it as PI_CODING_AGENT_DIR. */
17
28
  export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
18
29
  /** Stable mask: mapped back by field, without exposing key fragments. */
@@ -94,15 +105,11 @@ export class PiConfigStore {
94
105
  }
95
106
  return scope.kind === "global" ? join(this.agentDir, name) : join(scope.cwd, name);
96
107
  }
97
- resourceRoot(scope, kind) {
98
- return scope.kind === "global"
99
- ? join(this.agentDir, kind)
100
- : join(scope.cwd, ".pi", kind);
101
- }
102
108
  async listFiles(scope) {
103
109
  return Promise.all(this.fileNames(scope).map(async (name) => ({
104
110
  name,
105
111
  exists: await pathExists(this.filePath(scope, name)),
112
+ readonly: READONLY_FILES.includes(name),
106
113
  })));
107
114
  }
108
115
  async readFile(scope, name) {
@@ -115,7 +122,10 @@ export class PiConfigStore {
115
122
  }
116
123
  async writeFile(scope, name, content, expected) {
117
124
  const path = this.filePath(scope, name);
118
- return this.#withWrite(async () => {
125
+ if (READONLY_FILES.includes(name)) {
126
+ throw new Error(`${name} is written by Pier; edit it on disk, then run pier reload`);
127
+ }
128
+ return this.withWrite(async () => {
119
129
  const current = await readOptional(path);
120
130
  const visible = name === "models.json" ? maskModels(current) : current;
121
131
  if (expected !== undefined && visible !== expected)
@@ -160,7 +170,7 @@ export class PiConfigStore {
160
170
  }
161
171
  async setupProvider(input, verify) {
162
172
  validateProviderSetup(input);
163
- await this.#withWrite(async () => {
173
+ await this.withWrite(async () => {
164
174
  const path = join(this.agentDir, "models.json");
165
175
  const raw = await readNullable(path);
166
176
  const parsed = raw?.trim() ? parseModels(raw) : {};
@@ -229,26 +239,49 @@ export class PiConfigStore {
229
239
  }
230
240
  });
231
241
  }
242
+ async readDefaults() {
243
+ await this.#writes;
244
+ this.#assertSnapshot();
245
+ return settingsDefaults(readSettings(await readNullable(join(this.agentDir, "settings.json"))));
246
+ }
247
+ writeDefaults(defaults) {
248
+ return this.withWrite(async () => {
249
+ const path = join(this.agentDir, "settings.json");
250
+ const raw = await readNullable(path);
251
+ const next = withDefaults(raw, defaults);
252
+ if (typeof next !== "string" || next === raw)
253
+ return;
254
+ await fs.mkdir(this.agentDir, { recursive: true });
255
+ await atomicWrite(path, next);
256
+ });
257
+ }
258
+ /** Once per boot, before a session opens: SEED_SETTINGS when there is no
259
+ * settings.json at all. An existing file, whatever it holds, is never touched. */
260
+ seedSettings() {
261
+ return this.withWrite(async () => {
262
+ const path = join(this.agentDir, "settings.json");
263
+ if (await pathExists(path))
264
+ return;
265
+ await fs.mkdir(this.agentDir, { recursive: true });
266
+ await atomicWrite(path, `${JSON.stringify(SEED_SETTINGS, null, 2)}\n`);
267
+ });
268
+ }
232
269
  exportSnapshot() {
233
- return this.#withWrite(async () => {
270
+ return this.withWrite(async () => {
234
271
  const [system, agents, raw, rawSettings] = await Promise.all(SNAPSHOT_FILES.map((name) => readNullable(join(this.agentDir, name))));
235
272
  const parsed = raw?.trim() ? parseModels(raw) : {};
236
273
  if (!parsed)
237
274
  throw new Error("models.json must be valid JSON before exporting configuration");
238
- const settings = readSettings(rawSettings);
239
275
  return normalizeAgentSnapshot({
240
276
  files: { "SYSTEM.md": system, "AGENTS.md": agents },
241
277
  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,
278
+ ...settingsDefaults(readSettings(rawSettings)),
246
279
  });
247
280
  });
248
281
  }
249
282
  async applySnapshot(snapshot, commit) {
250
283
  const incoming = normalizeAgentSnapshot(snapshot);
251
- await this.#withWrite(async () => {
284
+ await this.withWrite(async () => {
252
285
  const names = SNAPSHOT_FILES;
253
286
  const before = await Promise.all(names.map((name) => readNullable(join(this.agentDir, name))));
254
287
  const raw = before[2];
@@ -312,50 +345,14 @@ export class PiConfigStore {
312
345
  }
313
346
  });
314
347
  }
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) {
348
+ /** The one queue every writer of this directory holds: settings.json has two
349
+ * writers (writeDefaults and Pi's SettingsManager in agent/packages.ts),
350
+ * and Pi reads several files directly when it opens a session. */
351
+ withWrite(write) {
320
352
  const result = this.#writes.then(() => { this.#assertSnapshot(); return write(); });
321
353
  this.#writes = result.then(() => undefined, () => undefined);
322
354
  return result;
323
355
  }
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
356
  }
360
357
  /** models.json may contain legacy keys and literal headers. Only stable
361
358
  * masks cross the HTTP seam; malformed content must be repaired on disk. */
@@ -495,6 +492,13 @@ function defaultModelRef(settings) {
495
492
  }
496
493
  return { provider, id };
497
494
  }
495
+ function settingsDefaults(settings) {
496
+ const level = settings.defaultThinkingLevel;
497
+ if (level !== undefined && !isThinkingLevel(level)) {
498
+ throw new Error("settings.json default reasoning effort must be a level Pi accepts");
499
+ }
500
+ return { defaultModel: defaultModelRef(settings), defaultThinkingLevel: level ?? null };
501
+ }
498
502
  /** The incoming defaults replace their own fields and nothing else; a field
499
503
  * the source never states (an older one) leaves the local settings.json
500
504
  * alone, and `null` is a stated "no default" that clears it. Unchanged
@@ -33,8 +33,7 @@ function systemOrigin(message) {
33
33
  }
34
34
  if (origin.kind === "task-message" &&
35
35
  typeof origin.messageId === "string" &&
36
- (origin.messageKind === "steer" || origin.messageKind === "follow_up" ||
37
- origin.messageKind === "progress" || origin.messageKind === "decision" || origin.messageKind === "reply"))
36
+ (origin.messageKind === "steer" || origin.messageKind === "follow_up"))
38
37
  return shape;
39
38
  return null;
40
39
  }
@@ -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,347 @@
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
+ /** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): a `local` file whose
18
+ * switch is the rtk tool's, so a settings.json pattern would fight the tool. */
19
+ const RTK_FILE = join("extensions", "rtk.ts");
20
+ const RTK_STATE = "installed by the rtk tool";
21
+ export const kindOf = (source) => source.startsWith("npm:") ? "npm" : /^(git:|(https?|ssh|git):\/\/)/.test(source) ? "git" : "path";
22
+ /** A trailing `@ref` on an npm or git source; the leading `@` of a scope is not one. */
23
+ export const pinnedRef = (source) => {
24
+ const at = source.lastIndexOf("@");
25
+ const ref = at > 0 ? source.slice(at + 1) : "";
26
+ return ref && !/[/:]/.test(ref) ? ref : null;
27
+ };
28
+ /** An extension is named by its file (or the directory its index sits in); a
29
+ * skill by its directory. Pi reads the frontmatter name at load, not here. */
30
+ const nameOf = (kind, path) => {
31
+ const file = basename(path);
32
+ if (kind === "skill")
33
+ return file === "SKILL.md" ? basename(dirname(path)) : file.replace(/\.md$/, "");
34
+ const stem = file.replace(/\.[cm]?[jt]sx?$/, "");
35
+ return stem === "index" ? basename(dirname(path)) : stem;
36
+ };
37
+ const versionFile = async (dir) => {
38
+ if (!dir)
39
+ return null;
40
+ try {
41
+ const { version } = JSON.parse(await fs.readFile(join(dir, "package.json"), "utf8"));
42
+ return typeof version === "string" ? version : null;
43
+ }
44
+ catch (err) {
45
+ if (err instanceof Error && "code" in err && err.code === "ENOENT")
46
+ return null;
47
+ log.warn(`${dir}/package.json unreadable — no version shown`, err);
48
+ return null;
49
+ }
50
+ };
51
+ /** Pi's own toggle rule (its config selector): one `+path`/`-path` per resource,
52
+ * any earlier pattern for the same path removed first. A bare entry stays: it
53
+ * is what lists the file, not a pattern on it. */
54
+ const flip = (entries, pattern, enabled) => [
55
+ ...entries.filter((p) => !(/^[!+-]/.test(p) && p.slice(1) === pattern)),
56
+ `${enabled ? "+" : "-"}${pattern}`,
57
+ ];
58
+ /** What npm, git or Pi's manifest refused is the upstream's failure, answered
59
+ * as such (502) with Pi's own sentence; a PackageError passes through. */
60
+ const upstream = async (what, run) => {
61
+ try {
62
+ return await run();
63
+ }
64
+ catch (err) {
65
+ if (err instanceof PackageError)
66
+ throw err;
67
+ throw new PackageError("unreachable", `${what} failed: ${err instanceof Error ? err.message : String(err)}`);
68
+ }
69
+ };
70
+ const resourceRow = (kind, r) => ({
71
+ kind, name: nameOf(kind, r.path), path: r.path, enabled: r.enabled, state: null,
72
+ });
73
+ export class PiPackageStore {
74
+ config;
75
+ pier;
76
+ skillDirs;
77
+ agentDir;
78
+ #busy = null;
79
+ #updates = new Set();
80
+ #checkedAt = null;
81
+ constructor(config, pier,
82
+ /** Pier's own skill directories, the ones pi.ts loads per session. */
83
+ skillDirs, agentDir = defaultAgentDir()) {
84
+ this.config = config;
85
+ this.pier = pier;
86
+ this.skillDirs = skillDirs;
87
+ this.agentDir = agentDir;
88
+ }
89
+ /** Per call, never retained: Pi's manager caches nothing Pier wants to keep,
90
+ * and a fresh SettingsManager reads whatever writeDefaults wrote last. */
91
+ #open(cwd) {
92
+ // Untrusted without a cwd: the project scope of the agent dir is nothing.
93
+ const settings = SettingsManager.create(cwd ?? this.agentDir, this.agentDir, { projectTrusted: cwd !== undefined });
94
+ this.#raise(settings, "read");
95
+ const manager = new DefaultPackageManager({ cwd: cwd ?? this.agentDir, agentDir: this.agentDir, settingsManager: settings });
96
+ manager.setProgressCallback((e) => log.info(`${e.action} ${e.source}: ${e.type}${e.message ? ` — ${e.message}` : ""}`));
97
+ return { settings, manager };
98
+ }
99
+ /** Pi's SettingsManager records a failed load or write and carries on (§5). */
100
+ #raise(settings, what) {
101
+ const errors = settings.drainErrors();
102
+ if (!errors.length)
103
+ return;
104
+ const detail = errors.map((e) => `${e.path ?? e.scope}: ${e.error.message}`).join("; ");
105
+ throw new PackageError("invalid", `settings.json could not be ${what}: ${detail}`);
106
+ }
107
+ async list(cwd) {
108
+ const { manager } = this.#open(cwd);
109
+ const rtkPath = join(this.agentDir, RTK_FILE);
110
+ const resolved = await manager.resolve(async () => "skip");
111
+ const rows = new Map();
112
+ const row = (source, kind, scope, installedPath) => {
113
+ const key = `${scope}\0${source}`;
114
+ let found = rows.get(key);
115
+ if (!found) {
116
+ found = { source, kind, scope, version: null, installedPath, updateAvailable: false, resources: [] };
117
+ rows.set(key, found);
118
+ }
119
+ return found;
120
+ };
121
+ row("pier", "pier", "global", null);
122
+ row("local", "local", "global", this.agentDir);
123
+ for (const key of Object.keys(KINDS)) {
124
+ for (const r of resolved[key]) {
125
+ const own = r.metadata.origin === "top-level";
126
+ const scope = r.metadata.scope === "project" ? "project" : "global";
127
+ const resource = resourceRow(KINDS[key], r);
128
+ if (r.path === rtkPath)
129
+ Object.assign(resource, { state: RTK_STATE, locked: true });
130
+ row(own ? "local" : r.metadata.source, own ? "local" : kindOf(r.metadata.source), scope, r.metadata.baseDir ?? null)
131
+ .resources.push(resource);
132
+ }
133
+ }
134
+ // Configured but unresolved (not installed, or empty): still a row.
135
+ for (const p of manager.listConfiguredPackages()) {
136
+ const found = row(p.source, kindOf(p.source), p.scope === "project" ? "project" : "global", p.installedPath ?? null);
137
+ found.installedPath ??= p.installedPath ?? null;
138
+ found.updateAvailable = this.#updates.has(p.source);
139
+ }
140
+ // pier, local, the global packages, then the project's rows; Pi's own order is precedence.
141
+ const rank = (p) => p.kind === "pier" ? 0 : p.scope === "project" ? 3 : p.kind === "local" ? 1 : 2;
142
+ const packages = [...rows.values()].sort((a, b) => rank(a) - rank(b));
143
+ for (const pkg of packages) {
144
+ if (pkg.kind === "pier")
145
+ await this.#fillPier(pkg);
146
+ else if (pkg.kind === "git")
147
+ pkg.version = pinnedRef(pkg.source);
148
+ else if (pkg.kind !== "local")
149
+ pkg.version = await versionFile(pkg.installedPath);
150
+ }
151
+ return { packages, checkedAt: this.#checkedAt, busy: this.#busy };
152
+ }
153
+ async #fillPier(pkg) {
154
+ pkg.version = this.pier.version;
155
+ const { extensions: on, skillsOff: off } = this.pier.settings.get();
156
+ // Off is off: a stand-down recorded while it was on is not this switch's state.
157
+ pkg.resources = BUNDLED.map(({ name }) => ({
158
+ kind: "extension", name, path: `<inline:${name}>`, enabled: on.includes(name),
159
+ state: on.includes(name) ? shadowedBuiltin(name) : null,
160
+ }));
161
+ for (const dir of this.skillDirs) {
162
+ for (const entry of (await fs.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory())) {
163
+ const path = join(dir, entry.name, "SKILL.md");
164
+ if (!existsSync(path))
165
+ continue;
166
+ pkg.resources.push({ kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name), state: null });
167
+ }
168
+ }
169
+ }
170
+ /** One at a time: the second Console click is told, not queued behind an npm install. */
171
+ async #operate(source, run) {
172
+ if (this.#busy)
173
+ throw new PackageError("busy", `${this.#busy} is being changed; try again when it finishes`);
174
+ this.#busy = source;
175
+ try {
176
+ return await this.#write(run);
177
+ }
178
+ finally {
179
+ this.#busy = null;
180
+ }
181
+ }
182
+ /** Inside ConfigStore's queue, so writeDefaults and a session open never
183
+ * interleave with Pi's merge-write; flushed and checked before it ends. */
184
+ #write(run, cwd) {
185
+ return this.config.withWrite(async () => {
186
+ const { manager, settings } = this.#open(cwd);
187
+ const result = await run(manager, settings);
188
+ await settings.flush();
189
+ this.#raise(settings, "written");
190
+ return result;
191
+ });
192
+ }
193
+ async #row(source) {
194
+ const found = (await this.list()).packages.find((p) => p.source === source);
195
+ if (!found)
196
+ throw new PackageError("missing", `${source} is not a configured package`);
197
+ return found;
198
+ }
199
+ /** `local` is one row per scope: the resource, not the source, picks the row. */
200
+ async #find({ source, kind, path, cwd }) {
201
+ const owners = (await this.list(cwd)).packages.filter((p) => p.source === source);
202
+ if (!owners.length)
203
+ throw new PackageError("missing", `${source} is not a configured package`);
204
+ for (const pkg of owners) {
205
+ const resource = pkg.resources.find((r) => r.kind === kind && r.path === path);
206
+ if (resource)
207
+ return { pkg, resource };
208
+ }
209
+ throw new PackageError("missing", `${source} has no ${kind} at ${path}`);
210
+ }
211
+ #builtin(source) {
212
+ if (source === "pier" || source === "local")
213
+ throw new PackageError("refused", `${source} is built in`);
214
+ }
215
+ async install(source) {
216
+ const spec = source.trim();
217
+ if (!spec)
218
+ throw new PackageError("invalid", "a package source is required");
219
+ this.#builtin(spec);
220
+ if (kindOf(spec) === "path" && !existsSync(resolve(this.agentDir, spec))) {
221
+ throw new PackageError("invalid", `no such path: ${resolve(this.agentDir, spec)}`);
222
+ }
223
+ // Pi stores a local path relative to the settings file: the row is named
224
+ // by what landed in settings.json, not by what was typed.
225
+ const stored = await this.#operate(spec, async (manager) => {
226
+ const sources = () => manager.listConfiguredPackages().filter((p) => p.scope === "user").map((p) => p.source);
227
+ const before = new Set(sources());
228
+ await upstream(`installing ${spec}`, () => manager.installAndPersist(spec));
229
+ const added = sources().find((s) => !before.has(s));
230
+ if (!added)
231
+ throw new PackageError("refused", `${spec} is already configured`);
232
+ return added;
233
+ });
234
+ return this.#row(stored);
235
+ }
236
+ async remove(source) {
237
+ this.#builtin(source);
238
+ await this.#operate(source, async (manager) => {
239
+ if (!manager.listConfiguredPackages().some((p) => p.scope === "user" && p.source === source)) {
240
+ throw new PackageError("missing", `${source} is not in settings.json`);
241
+ }
242
+ await upstream(`removing ${source}`, () => manager.removeAndPersist(source));
243
+ });
244
+ }
245
+ async update(source) {
246
+ const configured = this.#open().manager.listConfiguredPackages().filter((p) => p.scope === "user");
247
+ const movable = (s) => kindOf(s) !== "path" && pinnedRef(s) === null;
248
+ if (source !== undefined) {
249
+ this.#builtin(source);
250
+ if (!configured.some((p) => p.source === source))
251
+ throw new PackageError("missing", `${source} is not in settings.json`);
252
+ if (!movable(source))
253
+ throw new PackageError("refused", `${source} is pinned or local — nothing to move`);
254
+ }
255
+ const targets = source === undefined ? configured.map((p) => p.source).filter(movable) : [source];
256
+ await this.#operate(source ?? "every package", (manager) => upstream(`updating ${source ?? "every package"}`, () => manager.update(source)));
257
+ for (const s of targets)
258
+ this.#updates.delete(s);
259
+ const { packages } = await this.list();
260
+ return packages.filter((p) => p.scope === "global" && targets.includes(p.source));
261
+ }
262
+ async setEnabled(change) {
263
+ const { source, kind, path, enabled, cwd } = change;
264
+ const { pkg, resource } = await this.#find(change);
265
+ if (resource.locked)
266
+ throw new PackageError("refused", `${resource.name} is ${resource.state} — its switch is under Tools`);
267
+ if (pkg.kind === "pier") {
268
+ const { extensions, skillsOff } = this.pier.settings.get();
269
+ if (kind === "skill")
270
+ this.pier.settings.setSkillsOff(withName(skillsOff, resource.name, !enabled));
271
+ else
272
+ this.pier.settings.setExtensions(withName(extensions, resource.name, enabled));
273
+ }
274
+ else {
275
+ const key = kind === "extension" ? "extensions" : "skills";
276
+ await this.#write(async (manager, settings) => {
277
+ // Re-resolved under the lock: the pattern is relative to the base Pi
278
+ // resolved it from, which is the only base its filter will match.
279
+ const r = (await manager.resolve(async () => "skip"))[key].find((it) => it.path === path);
280
+ if (!r)
281
+ throw new PackageError("missing", `${path} is not loaded from ${source}`);
282
+ const project = cwd !== undefined;
283
+ const inherited = project && r.metadata.scope !== "project";
284
+ const scoped = project ? settings.getProjectSettings() : settings.getGlobalSettings();
285
+ const projectDir = join(cwd ?? this.agentDir, ".pi");
286
+ if (r.metadata.origin === "top-level") {
287
+ // A global resource switched for one project is named by its absolute path there.
288
+ const pattern = inherited ? path : relative(r.metadata.baseDir ?? (project ? projectDir : this.agentDir), path);
289
+ const next = flip(scoped[key] ?? [], pattern, enabled);
290
+ if (inherited && !next.includes(path))
291
+ next.unshift(path);
292
+ if (project)
293
+ settings[key === "extensions" ? "setProjectExtensionPaths" : "setProjectSkillPaths"](next);
294
+ else
295
+ settings[key === "extensions" ? "setExtensionPaths" : "setSkillPaths"](next);
296
+ }
297
+ else {
298
+ const pattern = relative(r.metadata.baseDir ?? dirname(path), path);
299
+ const packages = [...(scoped.packages ?? [])];
300
+ let at = packages.findIndex((p) => (typeof p === "string" ? p : p.source) === source);
301
+ // A global package overridden for one project: Pi's delta entry, a
302
+ // local path re-based on .pi/ because that is where Pi resolves it from.
303
+ if (at === -1 && inherited) {
304
+ const own = kindOf(source) === "path" ? relative(projectDir, resolve(this.agentDir, source)) || "." : source;
305
+ at = packages.push({ source: own, autoload: false }) - 1;
306
+ }
307
+ if (at === -1)
308
+ throw new PackageError("missing", `${source} is not in settings.json`);
309
+ const entry = packages[at];
310
+ const filtered = typeof entry === "string" ? { source: entry } : { ...entry };
311
+ filtered[key] = flip(filtered[key] ?? [], pattern, enabled);
312
+ packages[at] = filtered;
313
+ if (project)
314
+ settings.setProjectPackages(packages);
315
+ else
316
+ settings.setPackages(packages);
317
+ }
318
+ }, cwd);
319
+ }
320
+ // By path, not source: an override moves the resource to the project's row.
321
+ const after = (await this.list(cwd)).packages.flatMap((p) => p.resources).find((r) => r.kind === kind && r.path === path);
322
+ if (!after)
323
+ throw new PackageError("missing", `${path} is no longer loaded`);
324
+ return after;
325
+ }
326
+ async checkUpdates() {
327
+ const { manager } = this.#open();
328
+ const found = await upstream("update check", () => manager.checkForAvailableUpdates());
329
+ this.#updates = new Set(found.filter((u) => u.scope === "user").map((u) => u.source));
330
+ this.#checkedAt = new Date().toISOString();
331
+ if (this.#updates.size)
332
+ log.info(`package updates available: ${[...this.#updates].join(", ")}`);
333
+ return this.list();
334
+ }
335
+ /** At boot and daily, like update.ts; the answer waits in `list` for the next
336
+ * Console open. Returns its own stop. */
337
+ watchUpdates(everyMs = CHECK_EVERY_MS) {
338
+ const tick = () => {
339
+ void this.checkUpdates().catch((err) => log.warn(String(err)));
340
+ };
341
+ tick();
342
+ const timer = setInterval(tick, everyMs);
343
+ timer.unref();
344
+ return () => clearInterval(timer);
345
+ }
346
+ }
347
+ const withName = (names, name, on) => on ? [...new Set([...names, name])] : names.filter((n) => n !== name);