@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.
- package/README.md +8 -3
- package/dist/agent/config.js +63 -59
- package/dist/agent/events.js +1 -2
- package/dist/agent/listing.js +38 -15
- package/dist/agent/packages.js +347 -0
- package/dist/agent/pi.js +33 -59
- package/dist/channels/config.js +30 -20
- package/dist/channels/routes.js +0 -3
- package/dist/channels/slack-api.js +19 -10
- package/dist/channels/slack-cli.js +503 -0
- package/dist/channels/slack-directory.js +2 -2
- package/dist/channels/slack-outbound.js +1 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +9 -17
- package/dist/channels/types.js +0 -1
- package/dist/cli.js +158 -7
- package/dist/core/identity.js +20 -11
- package/dist/core/reply.js +16 -15
- package/dist/core/router.js +4 -1
- package/dist/core/types.js +7 -0
- package/dist/db.js +28 -0
- package/dist/extensions/index.js +1 -20
- package/dist/main.js +34 -42
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/settings.js +5 -0
- package/dist/socket.js +95 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +215 -0
- package/dist/tasks/definitions.js +3 -3
- package/dist/tasks/execution.js +1 -3
- package/dist/tasks/groups.js +2 -5
- package/dist/tasks/messages.js +18 -158
- package/dist/tasks/operations.js +323 -0
- package/dist/tasks/routes.js +1 -11
- package/dist/tasks/runs.js +5 -20
- package/dist/tasks/service.js +18 -43
- package/dist/tasks/store.js +15 -15
- package/dist/tools.js +63 -85
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/config.js +25 -11
- package/dist/web/explorer.js +5 -3
- package/dist/web/instance.js +6 -18
- package/dist/web/packages.js +84 -0
- package/dist/web/public/assets/{activity-DOr8dWeX.js → activity-NLS8W9yl.js} +2 -2
- package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
- package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js +1 -0
- package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js +4 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css +2 -0
- package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js +1 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +3 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js +4 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/server.js +3 -1
- package/dist/web/vault.js +68 -0
- package/docs/deploy.md +42 -8
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +10 -6
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +48 -145
- package/skills/pier-vault/SKILL.md +36 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/tasks/tool.js +0 -416
- package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
- package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js +0 -1
- package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js +0 -4
- package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js +0 -85
- package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js +0 -1
- package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js +0 -5
- package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js +0 -3
- package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js +0 -4
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- 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**
|
|
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
|
package/dist/agent/config.js
CHANGED
|
@@ -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
|
|
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
|
|
13
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
316
|
-
|
|
317
|
-
|
|
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
|
package/dist/agent/events.js
CHANGED
|
@@ -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
|
}
|
package/dist/agent/listing.js
CHANGED
|
@@ -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.
|
|
81
|
-
* the
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
87
|
-
const end = Math.min(text.length,
|
|
88
|
-
|
|
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
|
-
/**
|
|
198
|
-
*
|
|
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
|
|
202
|
-
|
|
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(`"${
|
|
205
|
-
: sql(`SELECT session_id, role, at, text FROM session_fts WHERE text LIKE ? ESCAPE '\\' ORDER BY at DESC`).iterate(`%${
|
|
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 ?? "",
|
|
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);
|