@agentprojectcontext/apx 1.74.2 → 1.75.0

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/prompt-builder.js +26 -7
  3. package/src/core/agent/render-template.js +22 -0
  4. package/src/core/profiles/block.js +290 -0
  5. package/src/core/profiles/bundled/secretary/PROFILE.es.md +44 -0
  6. package/src/core/profiles/bundled/secretary/PROFILE.md +44 -0
  7. package/src/core/profiles/bundled/secretary/channels/routine.md +43 -0
  8. package/src/core/profiles/bundled/secretary/config.schema.json +49 -0
  9. package/src/core/profiles/bundled/secretary/profile.json +20 -0
  10. package/src/core/profiles/bundled/secretary/routines/day-close.json +10 -0
  11. package/src/core/profiles/bundled/secretary/routines/day-open.json +10 -0
  12. package/src/core/profiles/index.js +16 -0
  13. package/src/core/profiles/lifecycle.js +720 -0
  14. package/src/core/profiles/manifest.js +193 -0
  15. package/src/core/profiles/paths.js +51 -0
  16. package/src/core/profiles/store.js +184 -0
  17. package/src/core/runtime-skills/apx-profile/SKILL.md +126 -0
  18. package/src/core/stores/routines.js +9 -1
  19. package/src/host/daemon/api/profiles.js +179 -0
  20. package/src/host/daemon/api/web.js +1 -1
  21. package/src/host/daemon/api.js +2 -0
  22. package/src/interfaces/cli/commands/profile.js +252 -0
  23. package/src/interfaces/cli/index.js +62 -0
  24. package/src/interfaces/web/dist/assets/index-CQ5kyFej.css +1 -0
  25. package/src/interfaces/web/dist/assets/{index-CQTIGYCu.js → index-CXeqTvfy.js} +165 -160
  26. package/src/interfaces/web/dist/assets/index-CXeqTvfy.js.map +1 -0
  27. package/src/interfaces/web/dist/index.html +2 -2
  28. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +245 -0
  29. package/src/interfaces/web/src/hooks/useProfiles.ts +37 -0
  30. package/src/interfaces/web/src/i18n/en.ts +31 -0
  31. package/src/interfaces/web/src/i18n/es.ts +31 -0
  32. package/src/interfaces/web/src/lib/api/profiles.ts +86 -0
  33. package/src/interfaces/web/src/screens/SettingsScreen.tsx +6 -2
  34. package/src/interfaces/web/dist/assets/index-COrRuBp1.css +0 -1
  35. package/src/interfaces/web/dist/assets/index-CQTIGYCu.js.map +0 -1
@@ -0,0 +1,720 @@
1
+ // Profile lifecycle: install / use / off / config / doctor / uninstall.
2
+ //
3
+ // Install and activate are separate operations on purpose — installing puts a
4
+ // package within reach and validates it; `use` is the moment the super-agent's
5
+ // behaviour actually changes.
6
+ //
7
+ // Nothing here touches user data. Turning a profile off disables the routines
8
+ // it installed but deletes nothing, so `off` → `use` is a round-trip that keeps
9
+ // settings, tasks and memory intact.
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import crypto from "node:crypto";
13
+
14
+ import { readConfig, writeConfig } from "../config/index.js";
15
+ import { projectStorageRoot, DEFAULT_PROJECT_ID } from "../config/paths.js";
16
+ import { readIdentity } from "../identity/index.js";
17
+ import { renderPromptTemplate } from "../agent/render-template.js";
18
+ import {
19
+ listRoutines,
20
+ upsertRoutine,
21
+ setEnabled,
22
+ deleteRoutine,
23
+ } from "../stores/routines.js";
24
+
25
+ import {
26
+ PROFILES_DIR,
27
+ MANIFEST_FILE,
28
+ CONFIG_SCHEMA_FILE,
29
+ PROFILE_ID_RE,
30
+ userProfileDir,
31
+ } from "./paths.js";
32
+ import {
33
+ validateManifest,
34
+ validateConfigSchema,
35
+ validateConfigValues,
36
+ schemaDefaults,
37
+ } from "./manifest.js";
38
+ import {
39
+ readProfile,
40
+ listProfiles,
41
+ readProfileState,
42
+ readActiveProfile,
43
+ effectiveProfileConfig,
44
+ readProfileTombstones,
45
+ writeProfileTombstones,
46
+ resolvePromptFile,
47
+ } from "./store.js";
48
+ import {
49
+ renderProfilePrompt,
50
+ clearProfileBlockCache,
51
+ validateTemplateVars,
52
+ profileChannelFile,
53
+ } from "./block.js";
54
+
55
+ /** Rough token estimate. Same 4-chars-per-token rule scripts/ uses. */
56
+ export function estimateTokens(text) {
57
+ return Math.round(String(text || "").length / 4);
58
+ }
59
+
60
+ /**
61
+ * Fingerprint of a routine's *behaviour*, used to tell "exactly as the package
62
+ * installed it" from "the user has since edited it".
63
+ *
64
+ * It must normalise identically whether it is handed a rendered package spec or
65
+ * a record read back from routines.json, because upsertRoutine fills in
66
+ * defaults the spec may omit. `enabled` is deliberately excluded: turning a
67
+ * profile off disables its routines, and that must not read as a user edit.
68
+ */
69
+ function routineFingerprint(r) {
70
+ const canonical = {
71
+ kind: r?.kind || null,
72
+ schedule: r?.schedule || null,
73
+ spec: r?.spec || {},
74
+ permission_mode: r?.permission_mode || null,
75
+ allowed_tools: r?.allowed_tools || [],
76
+ pre_commands: r?.pre_commands || [],
77
+ post_commands: r?.post_commands || [],
78
+ skip_prompt_on: r?.skip_prompt_on || "signal",
79
+ };
80
+ return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
81
+ }
82
+
83
+ /**
84
+ * Settings saved for one profile, whether or not it is the active one.
85
+ *
86
+ * `profile.config` always mirrors the ACTIVE profile's settings, because that
87
+ * is the shape every reader expects. `profile.configs[<id>]` is the durable
88
+ * per-profile store behind it, so switching A → B → A gives A its own settings
89
+ * back instead of whatever B was configured with.
90
+ */
91
+ function savedConfigFor(cfg, id) {
92
+ const byId = cfg?.profile?.configs;
93
+ if (byId && typeof byId === "object" && byId[id]) return byId[id];
94
+ // First read after an upgrade: the active profile's flat config is its own.
95
+ if (cfg?.profile?.active === id) return cfg.profile.config || {};
96
+ return {};
97
+ }
98
+
99
+ /** Write settings for one profile into both the mirror and the per-id store. */
100
+ function persistConfigFor(cfg, id, values, { active }) {
101
+ const configs = { ...(cfg.profile?.configs || {}), [id]: values };
102
+ cfg.profile = {
103
+ ...(cfg.profile || {}),
104
+ active,
105
+ configs,
106
+ // The mirror always describes the ACTIVE profile, so it is {} while none
107
+ // is active. Never undefined — readers treat it as a plain object.
108
+ config: active ? (active === id ? values : configs[active] || {}) : {},
109
+ };
110
+ return cfg;
111
+ }
112
+
113
+ /** Where the super-agent's own routines live (they are not project-scoped). */
114
+ function superAgentStorage() {
115
+ return projectStorageRoot(DEFAULT_PROJECT_ID);
116
+ }
117
+
118
+ function apxVersion() {
119
+ try {
120
+ const pkg = new URL("../../../package.json", import.meta.url);
121
+ return JSON.parse(fs.readFileSync(pkg, "utf8")).version || null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // --------------------- source resolution ------------------------------------
128
+
129
+ /**
130
+ * Resolve what the user asked to install into a package directory.
131
+ *
132
+ * Kept behind one function so remote sources (a URL, a registry id) can be
133
+ * added later without the callers changing — see 01-SPEC § 10.
134
+ *
135
+ * @returns {{ kind: "bundled"|"path", id: string, dir: string }}
136
+ */
137
+ export function resolveInstallSource(source) {
138
+ const raw = String(source || "").trim();
139
+ if (!raw) throw new Error("profile install: missing <id|path>");
140
+
141
+ if (/^https?:\/\//i.test(raw)) {
142
+ throw new Error(
143
+ "profile install: remote sources are not supported yet — clone the package and install from a local path"
144
+ );
145
+ }
146
+
147
+ // A path if it looks like one, or if it exists on disk.
148
+ const looksLikePath = raw.includes("/") || raw.startsWith(".");
149
+ if (looksLikePath || fs.existsSync(raw)) {
150
+ const dir = path.resolve(raw);
151
+ if (!fs.existsSync(path.join(dir, MANIFEST_FILE))) {
152
+ throw new Error(`profile install: no ${MANIFEST_FILE} found in ${dir}`);
153
+ }
154
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), "utf8"));
155
+ const id = manifest.id || path.basename(dir);
156
+ if (!PROFILE_ID_RE.test(id)) {
157
+ throw new Error(`profile install: invalid id "${id}" (lowercase slug expected)`);
158
+ }
159
+ return { kind: "path", id, dir };
160
+ }
161
+
162
+ if (!PROFILE_ID_RE.test(raw)) {
163
+ throw new Error(`profile install: invalid id "${raw}" (lowercase slug expected)`);
164
+ }
165
+ const found = readProfile(raw);
166
+ if (!found) {
167
+ const known = listProfiles().map((p) => p.id);
168
+ throw new Error(
169
+ `profile install: "${raw}" not found` +
170
+ (known.length ? ` — available: ${known.join(", ")}` : "")
171
+ );
172
+ }
173
+ return { kind: "bundled", id: raw, dir: found.dir };
174
+ }
175
+
176
+ function copyDirSync(from, to) {
177
+ fs.mkdirSync(to, { recursive: true });
178
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
179
+ const src = path.join(from, entry.name);
180
+ const dst = path.join(to, entry.name);
181
+ if (entry.isDirectory()) copyDirSync(src, dst);
182
+ else if (entry.isFile()) fs.copyFileSync(src, dst);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Token cost of every language variant a package ships.
188
+ * @returns {{ lang: string, tokens: number }[]}
189
+ */
190
+ export function measureProfilePrompts(profile, globalConfig, identity = null) {
191
+ const base = {
192
+ ...globalConfig,
193
+ profile: { active: profile.id, config: schemaDefaults(profile.schema) },
194
+ };
195
+ return (profile.prompts || []).map((file) => {
196
+ const m = file.match(/^PROFILE\.([\w-]+)\.md$/);
197
+ const lang = m ? m[1] : "en";
198
+ const rendered = renderProfilePrompt(profile, { identity, globalConfig: base, lang });
199
+ return { lang, tokens: estimateTokens(rendered) };
200
+ });
201
+ }
202
+
203
+ /** Channel ids a profile ships an overlay for (profiles/<id>/channels/<ch>.md). */
204
+ export function listProfileChannels(profile) {
205
+ const dir = path.join(profile.dir, "channels");
206
+ if (!fs.existsSync(dir)) return [];
207
+ return fs
208
+ .readdirSync(dir)
209
+ .filter((f) => f.endsWith(".md"))
210
+ .map((f) => f.slice(0, -3))
211
+ .sort();
212
+ }
213
+
214
+ // --------------------- validation -------------------------------------------
215
+
216
+ /**
217
+ * Everything that can be checked without activating: manifest, schema, prompt
218
+ * renderability, and the declared token budget.
219
+ */
220
+ export function validateProfilePackage(profile, { globalConfig = null } = {}) {
221
+ const cfg = globalConfig || readConfig();
222
+ const identity = (() => { try { return readIdentity(); } catch { return null; } })();
223
+
224
+ const errors = [];
225
+ const warnings = [];
226
+
227
+ const m = validateManifest(profile.manifest, { apxVersion: apxVersion() });
228
+ errors.push(...m.errors);
229
+ warnings.push(...m.warnings);
230
+
231
+ const s = validateConfigSchema(profile.schema);
232
+ errors.push(...s.errors);
233
+ warnings.push(...s.warnings);
234
+
235
+ // The prompt must exist and render cleanly.
236
+ const promptFile = resolvePromptFile(profile.dir, "en");
237
+ let tokens = 0;
238
+ if (!promptFile) {
239
+ errors.push(`profile "${profile.id}": no PROFILE.md found in ${profile.dir}`);
240
+ } else {
241
+ // The install gate. The renderer strips a stray {{…}} at runtime as a
242
+ // safety net, but by then the package is installed and a broken sentence
243
+ // has already reached somebody's phone. Every variable a template uses must
244
+ // resolve to something before we let the package in.
245
+ const templateFiles = profile.prompts.map((f) => path.join(profile.dir, f));
246
+ for (const ch of listProfileChannels(profile)) {
247
+ templateFiles.push(profileChannelFile(profile.dir, ch));
248
+ }
249
+ for (const file of templateFiles.filter(Boolean)) {
250
+ let body = "";
251
+ try { body = fs.readFileSync(file, "utf8"); } catch { continue; }
252
+ const check = validateTemplateVars(body, profile.schema);
253
+ for (const e of check.errors) {
254
+ errors.push(`profile "${profile.id}": ${path.basename(file)} — ${e}`);
255
+ }
256
+ }
257
+
258
+ const rendered = renderProfilePrompt(profile, {
259
+ identity,
260
+ globalConfig: { ...cfg, profile: { active: profile.id, config: schemaDefaults(profile.schema) } },
261
+ lang: "en",
262
+ });
263
+ if (!rendered) {
264
+ errors.push(`profile "${profile.id}": PROFILE.md renders to nothing`);
265
+ }
266
+ if (rendered.includes("{{")) {
267
+ errors.push(`profile "${profile.id}": unresolved template variables survive rendering`);
268
+ }
269
+ tokens = estimateTokens(rendered);
270
+
271
+ // The budget applies to EVERY translation, not just English. A Spanish
272
+ // speaker pays for PROFILE.es.md, so checking only the base file would let
273
+ // a translation ship over budget for exactly the people who read it.
274
+ const budget = profile.manifest?.prompt_budget_tokens;
275
+ if (budget) {
276
+ for (const { lang, tokens: n } of measureProfilePrompts(profile, cfg, identity)) {
277
+ const which = lang === "en" ? "PROFILE.md" : `PROFILE.${lang}.md`;
278
+ if (n > budget * 1.5) {
279
+ errors.push(
280
+ `profile "${profile.id}": ${which} is ~${n} tokens, more than 1.5x its declared ` +
281
+ `budget of ${budget}. Trim it or raise prompt_budget_tokens.`
282
+ );
283
+ } else if (n > budget) {
284
+ warnings.push(
285
+ `profile "${profile.id}": ${which} is ~${n} tokens against a declared budget of ` +
286
+ `${budget}. It ships on every turn of every channel.`
287
+ );
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ return { ok: errors.length === 0, errors, warnings, tokens };
294
+ }
295
+
296
+ // --------------------- install ----------------------------------------------
297
+
298
+ /**
299
+ * Install a profile: validate it, make it resolvable, and seed its settings.
300
+ * Does NOT activate — that is `useProfile`.
301
+ */
302
+ export function installProfile(source, { force = false } = {}) {
303
+ const resolved = resolveInstallSource(source);
304
+ const warnings = [];
305
+
306
+ // A local package is copied into the user layer, because it lives outside
307
+ // APX and could move or vanish. A bundled package is NOT copied: copying it
308
+ // would shadow the version APX ships and freeze the user on today's content
309
+ // forever. See core/profiles/store.js.
310
+ if (resolved.kind === "path") {
311
+ const dest = userProfileDir(resolved.id);
312
+ if (fs.existsSync(dest) && !force) {
313
+ throw new Error(
314
+ `profile "${resolved.id}" is already installed at ${dest} — pass --force to overwrite`
315
+ );
316
+ }
317
+ if (path.resolve(resolved.dir) !== path.resolve(dest)) {
318
+ fs.rmSync(dest, { recursive: true, force: true });
319
+ copyDirSync(resolved.dir, dest);
320
+ }
321
+ }
322
+
323
+ // Un-tombstone: reinstalling a bundled profile the user had removed.
324
+ const tombstones = readProfileTombstones();
325
+ if (tombstones.delete(resolved.id)) writeProfileTombstones(tombstones);
326
+
327
+ clearProfileBlockCache();
328
+
329
+ const profile = readProfile(resolved.id);
330
+ if (!profile) throw new Error(`profile install: "${resolved.id}" did not resolve after install`);
331
+
332
+ const report = validateProfilePackage(profile);
333
+ if (!report.ok) {
334
+ // Roll the copy back so a failed install leaves nothing behind.
335
+ if (resolved.kind === "path") {
336
+ fs.rmSync(userProfileDir(resolved.id), { recursive: true, force: true });
337
+ clearProfileBlockCache();
338
+ }
339
+ throw new Error(`profile install failed:\n - ${report.errors.join("\n - ")}`);
340
+ }
341
+ warnings.push(...report.warnings);
342
+
343
+ // Seed settings with the schema defaults, keeping anything already saved for
344
+ // this profile from a previous install.
345
+ const cfg = readConfig();
346
+ const state = readProfileState(cfg);
347
+ const settings = { ...schemaDefaults(profile.schema), ...savedConfigFor(cfg, resolved.id) };
348
+ persistConfigFor(cfg, resolved.id, settings, { active: state.active });
349
+ cfg.profile.installed_at = new Date().toISOString();
350
+ cfg.profile.version = profile.manifest.version || null;
351
+ writeConfig(cfg);
352
+
353
+ return { profile, warnings, tokens: report.tokens, doctor: profileDoctor(resolved.id) };
354
+ }
355
+
356
+ // --------------------- routines ---------------------------------------------
357
+
358
+ /** The profile's routine specs, rendered against its effective settings. */
359
+ export function renderProfileRoutines(profile, globalConfig) {
360
+ const dir = path.join(profile.dir, "routines");
361
+ if (!fs.existsSync(dir)) return [];
362
+
363
+ const settings = effectiveProfileConfig(profile, globalConfig);
364
+ const out = [];
365
+
366
+ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
367
+ let raw;
368
+ try {
369
+ raw = JSON.parse(renderPromptTemplate(fs.readFileSync(path.join(dir, file), "utf8"), settings));
370
+ } catch (e) {
371
+ throw new Error(`profile "${profile.id}": routines/${file} is not valid JSON after rendering — ${e.message}`);
372
+ }
373
+ if (!raw?.name || !raw?.kind || !raw?.schedule) {
374
+ throw new Error(`profile "${profile.id}": routines/${file} needs name, kind and schedule`);
375
+ }
376
+ // Namespaced, because `name` is the real primary key of the routines store
377
+ // and a profile must never collide with a routine the user wrote.
378
+ out.push({ ...raw, name: `${profile.id}-${raw.name}` });
379
+ }
380
+ return out;
381
+ }
382
+
383
+ function profileOrigin(id) {
384
+ return `profile:${id}`;
385
+ }
386
+
387
+ /** Install (or refresh) the routines a profile brings. Returns a summary. */
388
+ export function syncProfileRoutines(profile, globalConfig, { enable = true } = {}) {
389
+ const storage = superAgentStorage();
390
+ const specs = renderProfileRoutines(profile, globalConfig);
391
+ const existing = listRoutines(storage);
392
+ const origin = profileOrigin(profile.id);
393
+
394
+ const installed = [];
395
+ const skipped = [];
396
+
397
+ for (const spec of specs) {
398
+ const { name, enabled_by_default, ...rest } = spec;
399
+ const prev = existing.find((r) => r.name === name);
400
+ const hash = routineFingerprint(rest);
401
+
402
+ // The user edited a routine this package installed → never touch it again.
403
+ // Compare the record against the fingerprint taken when it was installed,
404
+ // NOT against the newly rendered spec: a changed setting legitimately
405
+ // changes the rendering, and that must still be applied.
406
+ if (
407
+ prev &&
408
+ prev.origin === origin &&
409
+ prev.origin_hash &&
410
+ routineFingerprint(prev) !== prev.origin_hash
411
+ ) {
412
+ skipped.push({ name, reason: "user_modified" });
413
+ continue;
414
+ }
415
+ // A routine of the same name the user owns → do not hijack it.
416
+ if (prev && prev.origin && prev.origin !== origin) {
417
+ skipped.push({ name, reason: "owned_by_other" });
418
+ continue;
419
+ }
420
+ if (prev && !prev.origin) {
421
+ skipped.push({ name, reason: "user_owned" });
422
+ continue;
423
+ }
424
+
425
+ upsertRoutine(storage, {
426
+ ...rest,
427
+ name,
428
+ enabled: enable && enabled_by_default !== false,
429
+ origin,
430
+ origin_hash: hash,
431
+ });
432
+ installed.push(name);
433
+ }
434
+
435
+ return { installed, skipped };
436
+ }
437
+
438
+ /** Disable — never delete — the routines a profile installed. */
439
+ export function disableProfileRoutines(profileId) {
440
+ const storage = superAgentStorage();
441
+ const origin = profileOrigin(profileId);
442
+ const touched = [];
443
+ for (const r of listRoutines(storage)) {
444
+ if (r.origin === origin && r.enabled) {
445
+ setEnabled(storage, r.name, false);
446
+ touched.push(r.name);
447
+ }
448
+ }
449
+ return touched;
450
+ }
451
+
452
+ /** Remove the routines a profile installed, preserving any the user edited. */
453
+ export function removeProfileRoutines(profileId) {
454
+ const storage = superAgentStorage();
455
+ const origin = profileOrigin(profileId);
456
+ const removed = [];
457
+ const kept = [];
458
+ for (const r of listRoutines(storage)) {
459
+ if (r.origin !== origin) continue;
460
+ // No hash, or a hash that no longer matches, means the user made it theirs.
461
+ const isUntouched = !!r.origin_hash && r.origin_hash === routineFingerprint(r);
462
+ if (isUntouched) {
463
+ deleteRoutine(storage, r.name);
464
+ removed.push(r.name);
465
+ } else {
466
+ kept.push(r.name);
467
+ }
468
+ }
469
+ return { removed, kept };
470
+ }
471
+
472
+ // --------------------- use / off --------------------------------------------
473
+
474
+ export function useProfile(id, { confirmReplace = false } = {}) {
475
+ const profile = readProfile(id);
476
+ if (!profile) throw new Error(`profile "${id}" is not installed — run: apx profile install ${id}`);
477
+
478
+ const cfg = readConfig();
479
+ const state = readProfileState(cfg);
480
+
481
+ if (state.active && state.active !== id && !confirmReplace) {
482
+ throw new Error(
483
+ `profile "${state.active}" is already active. Only one profile runs at a time — ` +
484
+ `re-run with --force to replace it.`
485
+ );
486
+ }
487
+
488
+ const report = validateProfilePackage(profile, { globalConfig: cfg });
489
+ if (!report.ok) {
490
+ throw new Error(`profile "${id}" cannot be activated:\n - ${report.errors.join("\n - ")}`);
491
+ }
492
+
493
+ // Stand the previous profile's routines down before the new one's go up.
494
+ if (state.active && state.active !== id) disableProfileRoutines(state.active);
495
+
496
+ const settings = { ...schemaDefaults(profile.schema), ...savedConfigFor(cfg, id) };
497
+ persistConfigFor(cfg, id, settings, { active: id });
498
+ cfg.profile.version = profile.manifest.version || null;
499
+ writeConfig(cfg);
500
+ clearProfileBlockCache();
501
+
502
+ const routines = syncProfileRoutines(profile, cfg);
503
+ return { profile, routines, warnings: report.warnings, tokens: report.tokens };
504
+ }
505
+
506
+ export function offProfile() {
507
+ const cfg = readConfig();
508
+ const state = readProfileState(cfg);
509
+ if (!state.active) return { was: null, routines: [] };
510
+
511
+ const routines = disableProfileRoutines(state.active);
512
+
513
+ // Settings are kept in `configs`, so `use` again restores exactly what the
514
+ // user had. The `config` mirror describes the ACTIVE profile, so it empties
515
+ // out — leaving a deactivated profile's settings sitting there would make
516
+ // config.json read as though something were still active.
517
+ cfg.profile = { ...(cfg.profile || {}), active: null, config: {} };
518
+ writeConfig(cfg);
519
+ clearProfileBlockCache();
520
+
521
+ return { was: state.active, routines };
522
+ }
523
+
524
+ // --------------------- config -----------------------------------------------
525
+
526
+ /**
527
+ * Update the active profile's settings. Changing a schedule setting really
528
+ * moves the cron — the routines are re-rendered and re-installed.
529
+ */
530
+ export function setProfileConfig(values, { id = null } = {}) {
531
+ const cfg = readConfig();
532
+ const state = readProfileState(cfg);
533
+ const targetId = id || state.active;
534
+ if (!targetId) throw new Error("no profile is active — run: apx profile use <id>");
535
+
536
+ const profile = readProfile(targetId);
537
+ if (!profile) throw new Error(`profile "${targetId}" is not installed`);
538
+
539
+ const { ok, errors, value } = validateConfigValues(profile.schema, values);
540
+ if (!ok) throw new Error(`invalid profile config:\n - ${errors.join("\n - ")}`);
541
+
542
+ const settings = {
543
+ ...schemaDefaults(profile.schema),
544
+ ...savedConfigFor(cfg, targetId),
545
+ ...value,
546
+ };
547
+ persistConfigFor(cfg, targetId, settings, { active: state.active });
548
+ writeConfig(cfg);
549
+ clearProfileBlockCache();
550
+
551
+ // Changing day_open_at has to move the actual cron, not just the JSON — so
552
+ // the profile's routines are re-rendered and re-installed.
553
+ const routines =
554
+ state.active === targetId ? syncProfileRoutines(profile, cfg) : { installed: [], skipped: [] };
555
+
556
+ return { config: settings, changed: Object.keys(value), routines };
557
+ }
558
+
559
+ // --------------------- doctor -----------------------------------------------
560
+
561
+ /**
562
+ * What is missing for this profile to do its job. Actionable lines, not a
563
+ * status dump — every entry says what to run.
564
+ */
565
+ export function profileDoctor(id = null) {
566
+ const cfg = readConfig();
567
+ const state = readProfileState(cfg);
568
+ const targetId = id || state.active;
569
+
570
+ if (!targetId) {
571
+ return { id: null, active: false, ok: true, checks: [], summary: "No profile active (vanilla)." };
572
+ }
573
+
574
+ const profile = readProfile(targetId);
575
+ if (!profile) {
576
+ return {
577
+ id: targetId,
578
+ active: false,
579
+ ok: false,
580
+ checks: [{ level: "error", label: "package", detail: `not installed`, fix: `apx profile install ${targetId}` }],
581
+ summary: `profile "${targetId}" is not installed`,
582
+ };
583
+ }
584
+
585
+ const checks = [];
586
+ const report = validateProfilePackage(profile, { globalConfig: cfg });
587
+ for (const e of report.errors) checks.push({ level: "error", label: "package", detail: e, fix: null });
588
+ for (const w of report.warnings) checks.push({ level: "warn", label: "package", detail: w, fix: null });
589
+
590
+ const requires = profile.manifest?.requires || {};
591
+
592
+ // Channels the profile expects to speak through.
593
+ for (const ch of requires.channels || []) {
594
+ if (ch === "telegram") {
595
+ const configured = (cfg.telegram?.channels || []).length > 0 || !!cfg.telegram?.bot_token;
596
+ if (!configured) {
597
+ checks.push({
598
+ level: "warn",
599
+ label: "channel",
600
+ detail: `Telegram is not configured — the profile cannot reach you there`,
601
+ fix: "apx telegram setup",
602
+ });
603
+ }
604
+ }
605
+ }
606
+
607
+ // Integrations. Required ones block; optional ones degrade.
608
+ for (const slug of requires.integrations || []) {
609
+ if (!cfg.integrations?.[slug]) {
610
+ checks.push({ level: "error", label: "integration", detail: `${slug} is required and not connected`, fix: `apx integration connect ${slug}` });
611
+ }
612
+ }
613
+ for (const slug of requires.optional_integrations || []) {
614
+ if (!cfg.integrations?.[slug]) {
615
+ checks.push({ level: "warn", label: "integration", detail: `${slug} is not connected — the profile degrades without it`, fix: `apx integration connect ${slug}` });
616
+ }
617
+ }
618
+
619
+ // Core capabilities the package declares it needs. Unknown ones are reported
620
+ // rather than silently ignored, so a package can't quietly depend on nothing.
621
+ for (const cap of requires.capabilities || []) {
622
+ if (!CORE_CAPABILITIES.has(cap)) {
623
+ checks.push({
624
+ level: "warn",
625
+ label: "capability",
626
+ detail: `"${cap}" is not provided by this APX version — the profile degrades`,
627
+ fix: null,
628
+ });
629
+ }
630
+ }
631
+
632
+ // Routines it installed that are currently off.
633
+ if (state.active === targetId) {
634
+ const origin = profileOrigin(targetId);
635
+ const off = listRoutines(superAgentStorage()).filter((r) => r.origin === origin && !r.enabled);
636
+ for (const r of off) {
637
+ checks.push({ level: "warn", label: "routine", detail: `"${r.name}" is disabled`, fix: `apx routine enable ${r.name}` });
638
+ }
639
+ }
640
+
641
+ const errors = checks.filter((c) => c.level === "error").length;
642
+ return {
643
+ id: targetId,
644
+ active: state.active === targetId,
645
+ ok: errors === 0,
646
+ tokens: report.tokens,
647
+ budget: profile.manifest?.prompt_budget_tokens || null,
648
+ checks,
649
+ summary: errors === 0
650
+ ? `profile "${targetId}" is healthy${checks.length ? ` (${checks.length} warning(s))` : ""}`
651
+ : `profile "${targetId}" has ${errors} blocking problem(s)`,
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Core capabilities a profile package may declare in `requires.capabilities`.
657
+ * Grow this as the capabilities in 02-SPEC land.
658
+ */
659
+ export const CORE_CAPABILITIES = new Set([
660
+ "routine.memory",
661
+ ]);
662
+
663
+ // --------------------- uninstall --------------------------------------------
664
+
665
+ export function uninstallProfile(id) {
666
+ const profile = readProfile(id);
667
+ if (!profile) throw new Error(`profile "${id}" is not installed`);
668
+
669
+ const cfg = readConfig();
670
+ const state = readProfileState(cfg);
671
+
672
+ if (state.active === id) {
673
+ disableProfileRoutines(id);
674
+ cfg.profile = { ...(cfg.profile || {}), active: null, config: {} };
675
+ writeConfig(cfg);
676
+ }
677
+
678
+ const routines = removeProfileRoutines(id);
679
+
680
+ // A bundled package can't be deleted, so it gets a tombstone — the same
681
+ // mechanism the agent vault uses.
682
+ let removedDir = null;
683
+ if (profile.source === "bundled") {
684
+ const tombstones = readProfileTombstones();
685
+ tombstones.add(id);
686
+ writeProfileTombstones(tombstones);
687
+ } else {
688
+ removedDir = userProfileDir(id);
689
+ fs.rmSync(removedDir, { recursive: true, force: true });
690
+ // An override disappearing re-exposes the bundled package underneath.
691
+ if (profile.source === "user") {
692
+ const tombstones = readProfileTombstones();
693
+ if (tombstones.delete(id)) writeProfileTombstones(tombstones);
694
+ }
695
+ }
696
+
697
+ clearProfileBlockCache();
698
+ return { id, source: profile.source, removedDir, routines };
699
+ }
700
+
701
+ // --------------------- listing ----------------------------------------------
702
+
703
+ /** Everything a surface needs to render the profile list. */
704
+ export function listProfilesWithState(globalConfig = null) {
705
+ const cfg = globalConfig || readConfig();
706
+ const state = readProfileState(cfg);
707
+ return listProfiles().map((p) => ({
708
+ id: p.id,
709
+ name: p.manifest.name || p.id,
710
+ version: p.manifest.version || null,
711
+ description: p.manifest.description || "",
712
+ author: p.manifest.author || null,
713
+ languages: p.manifest.languages || ["en"],
714
+ source: p.source,
715
+ active: state.active === p.id,
716
+ dir: p.dir,
717
+ }));
718
+ }
719
+
720
+ export { readActiveProfile, readProfileState, effectiveProfileConfig };