@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,193 @@
1
+ // profile.json + config.schema.json validation.
2
+ //
3
+ // The schema support is a deliberate subset — type / enum / default / title /
4
+ // description / required — so a profile package can describe its white-label
5
+ // variables without APX taking on a JSON Schema dependency. Anything richer
6
+ // belongs in the profile's own logic, not in the manifest.
7
+ import { PROFILE_ID_RE } from "./paths.js";
8
+
9
+ const REQUIRED_FIELDS = ["id", "name", "version"];
10
+ const SUPPORTED_TYPES = new Set(["string", "integer", "number", "boolean"]);
11
+
12
+ /** Semver-ish compare. Returns -1 / 0 / 1. Ignores pre-release tags. */
13
+ export function compareVersions(a, b) {
14
+ const parse = (v) =>
15
+ String(v || "0")
16
+ .split("-")[0]
17
+ .split(".")
18
+ .map((n) => parseInt(n, 10) || 0);
19
+ const [x, y] = [parse(a), parse(b)];
20
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
21
+ const d = (x[i] || 0) - (y[i] || 0);
22
+ if (d !== 0) return d > 0 ? 1 : -1;
23
+ }
24
+ return 0;
25
+ }
26
+
27
+ /**
28
+ * Validate a profile manifest.
29
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
30
+ */
31
+ export function validateManifest(manifest, { apxVersion = null } = {}) {
32
+ const errors = [];
33
+ const warnings = [];
34
+
35
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
36
+ return { ok: false, errors: ["profile.json must be a JSON object"], warnings };
37
+ }
38
+
39
+ for (const field of REQUIRED_FIELDS) {
40
+ if (!manifest[field] || typeof manifest[field] !== "string") {
41
+ errors.push(`profile.json: "${field}" is required and must be a string`);
42
+ }
43
+ }
44
+
45
+ if (manifest.id && !PROFILE_ID_RE.test(manifest.id)) {
46
+ errors.push(
47
+ `profile.json: "id" must be a lowercase slug (a-z, 0-9, dashes) — got "${manifest.id}"`
48
+ );
49
+ }
50
+
51
+ if (manifest.languages != null && !Array.isArray(manifest.languages)) {
52
+ errors.push('profile.json: "languages" must be an array of language codes');
53
+ }
54
+
55
+ if (manifest.prompt_budget_tokens != null) {
56
+ const n = manifest.prompt_budget_tokens;
57
+ if (!Number.isInteger(n) || n <= 0) {
58
+ errors.push('profile.json: "prompt_budget_tokens" must be a positive integer');
59
+ }
60
+ }
61
+
62
+ // apx_min_version gates installation, but only warns when we can't tell.
63
+ if (manifest.apx_min_version) {
64
+ if (!apxVersion) {
65
+ warnings.push(
66
+ `could not determine the running APX version to check apx_min_version ` +
67
+ `(${manifest.apx_min_version})`
68
+ );
69
+ } else if (compareVersions(apxVersion, manifest.apx_min_version) < 0) {
70
+ errors.push(
71
+ `profile "${manifest.id}" needs APX >= ${manifest.apx_min_version}, ` +
72
+ `this is ${apxVersion}`
73
+ );
74
+ }
75
+ }
76
+
77
+ return { ok: errors.length === 0, errors, warnings };
78
+ }
79
+
80
+ /**
81
+ * Validate a config.schema.json (the white-label variable declaration).
82
+ * Every property must carry a default — installing a profile and configuring
83
+ * nothing has to yield a working system, not a questionnaire.
84
+ */
85
+ export function validateConfigSchema(schema) {
86
+ const errors = [];
87
+ const warnings = [];
88
+
89
+ if (schema == null) return { ok: true, errors, warnings };
90
+ if (typeof schema !== "object" || Array.isArray(schema)) {
91
+ return { ok: false, errors: ["config.schema.json must be a JSON object"], warnings };
92
+ }
93
+ if (schema.type && schema.type !== "object") {
94
+ errors.push('config.schema.json: top-level "type" must be "object"');
95
+ }
96
+
97
+ const props = schema.properties || {};
98
+ if (typeof props !== "object" || Array.isArray(props)) {
99
+ return { ok: false, errors: ['config.schema.json: "properties" must be an object'], warnings };
100
+ }
101
+
102
+ for (const [key, def] of Object.entries(props)) {
103
+ if (!def || typeof def !== "object") {
104
+ errors.push(`config.schema.json: property "${key}" must be an object`);
105
+ continue;
106
+ }
107
+ if (def.type && !SUPPORTED_TYPES.has(def.type)) {
108
+ errors.push(
109
+ `config.schema.json: property "${key}" has unsupported type "${def.type}" ` +
110
+ `(supported: ${[...SUPPORTED_TYPES].join(", ")})`
111
+ );
112
+ }
113
+ if (def.enum != null && (!Array.isArray(def.enum) || def.enum.length === 0)) {
114
+ errors.push(`config.schema.json: property "${key}" — "enum" must be a non-empty array`);
115
+ }
116
+ if (def.default === undefined) {
117
+ warnings.push(
118
+ `config.schema.json: property "${key}" has no default — a profile should work ` +
119
+ `before the user configures anything`
120
+ );
121
+ } else if (def.enum && !def.enum.includes(def.default)) {
122
+ errors.push(
123
+ `config.schema.json: property "${key}" — default "${def.default}" is not in its enum`
124
+ );
125
+ }
126
+ }
127
+
128
+ return { ok: errors.length === 0, errors, warnings };
129
+ }
130
+
131
+ /** Every property's default, as a plain object. Missing defaults are skipped. */
132
+ export function schemaDefaults(schema) {
133
+ const out = {};
134
+ const props = schema?.properties || {};
135
+ for (const [key, def] of Object.entries(props)) {
136
+ if (def && def.default !== undefined) out[key] = def.default;
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function coerce(value, type) {
142
+ if (type === "integer" || type === "number") {
143
+ const n = Number(value);
144
+ if (!Number.isFinite(n)) return { ok: false };
145
+ if (type === "integer" && !Number.isInteger(n)) return { ok: false };
146
+ return { ok: true, value: n };
147
+ }
148
+ if (type === "boolean") {
149
+ if (typeof value === "boolean") return { ok: true, value };
150
+ const s = String(value).toLowerCase();
151
+ if (["true", "1", "yes", "on"].includes(s)) return { ok: true, value: true };
152
+ if (["false", "0", "no", "off"].includes(s)) return { ok: true, value: false };
153
+ return { ok: false };
154
+ }
155
+ return { ok: true, value: String(value) };
156
+ }
157
+
158
+ /**
159
+ * Validate + coerce a config patch against the schema.
160
+ * CLI flags arrive as strings, so values are coerced to the declared type
161
+ * rather than rejected for being "3" instead of 3.
162
+ *
163
+ * @returns {{ ok: boolean, errors: string[], value: object }}
164
+ */
165
+ export function validateConfigValues(schema, values = {}) {
166
+ const errors = [];
167
+ const out = {};
168
+ const props = schema?.properties || {};
169
+
170
+ for (const [key, raw] of Object.entries(values || {})) {
171
+ const def = props[key];
172
+ if (!def) {
173
+ const known = Object.keys(props);
174
+ errors.push(
175
+ `unknown setting "${key}"` +
176
+ (known.length ? ` — this profile accepts: ${known.join(", ")}` : "")
177
+ );
178
+ continue;
179
+ }
180
+ const { ok, value } = coerce(raw, def.type || "string");
181
+ if (!ok) {
182
+ errors.push(`"${key}" must be a ${def.type} — got "${raw}"`);
183
+ continue;
184
+ }
185
+ if (def.enum && !def.enum.includes(value)) {
186
+ errors.push(`"${key}" must be one of: ${def.enum.join(", ")} — got "${value}"`);
187
+ continue;
188
+ }
189
+ out[key] = value;
190
+ }
191
+
192
+ return { ok: errors.length === 0, errors, value: out };
193
+ }
@@ -0,0 +1,51 @@
1
+ // Filesystem layout for installable profiles.
2
+ //
3
+ // Two layers, mirroring the agent vault (core/apc/parser.js):
4
+ // - BUNDLED → src/core/profiles/bundled/<id>/, shipped with APX, read-only.
5
+ // - USER → ~/.apx/profiles/<id>/, installed from a local path, plus
6
+ // copy-on-write overrides of a bundled package.
7
+ // - REMOVED → ~/.apx/profiles/.removed.json, tombstones for bundled ids the
8
+ // user uninstalled (a bundled package can't be deleted).
9
+ //
10
+ // Bundled packages live under src/ rather than assets/ on purpose: package.json
11
+ // `files` ships src/, skills/ and README.md only, so anything under assets/ is
12
+ // absent from an npm install. See docs-internal/secretary/00-findings.md § A.
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { APX_HOME } from "../config/paths.js";
16
+
17
+ const __profilesDir = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ /** Packages shipped with APX. Read-only on the user's machine. */
20
+ export const BUNDLED_PROFILES_DIR = path.join(__profilesDir, "bundled");
21
+
22
+ /** The user's own packages and overrides. */
23
+ export const PROFILES_DIR = path.join(APX_HOME, "profiles");
24
+
25
+ /** Tombstones — bundled ids the user removed. */
26
+ export const PROFILES_TOMBSTONE_PATH = path.join(PROFILES_DIR, ".removed.json");
27
+
28
+ export const MANIFEST_FILE = "profile.json";
29
+ export const PROMPT_FILE = "PROFILE.md";
30
+ export const CONFIG_SCHEMA_FILE = "config.schema.json";
31
+
32
+ /** Ids are slugs: lowercase, digits, dashes. Keeps them safe as path segments. */
33
+ export const PROFILE_ID_RE = /^[a-z][a-z0-9-]{0,63}$/;
34
+
35
+ export function bundledProfileDir(id) {
36
+ return path.join(BUNDLED_PROFILES_DIR, id);
37
+ }
38
+
39
+ export function userProfileDir(id) {
40
+ return path.join(PROFILES_DIR, id);
41
+ }
42
+
43
+ /**
44
+ * Language-specific prompt filename: PROFILE.es.md, PROFILE.pt-BR.md, …
45
+ * `null`/"en" means the base PROFILE.md.
46
+ */
47
+ export function promptFileFor(lang) {
48
+ const code = String(lang || "").trim();
49
+ if (!code || code.toLowerCase() === "en") return PROMPT_FILE;
50
+ return `PROFILE.${code}.md`;
51
+ }
@@ -0,0 +1,184 @@
1
+ // Profile package resolution — the layered read.
2
+ //
3
+ // This mirrors readVaultAgents() in core/apc/parser.js: bundled and user
4
+ // packages are resolved at READ time, user wins per id, tombstones hide bundled
5
+ // ids the user removed. Nothing is copied at install time.
6
+ //
7
+ // That distinction matters. If installing a bundled profile copied it into
8
+ // ~/.apx/profiles/, the user would be frozen at that version forever — a later
9
+ // `npm update` would ship an improved PROFILE.md that their copy shadows. So a
10
+ // user-layer directory exists only when the user genuinely owns that package:
11
+ // they installed it from a local path, or they explicitly ejected a bundled one
12
+ // to edit it.
13
+ //
14
+ // The user's *settings* are not part of the package. They live in
15
+ // ~/.apx/config.json under `profile.config`, so they survive package updates,
16
+ // `off` → `use` round-trips, and uninstall/reinstall.
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ import {
21
+ BUNDLED_PROFILES_DIR,
22
+ PROFILES_DIR,
23
+ PROFILES_TOMBSTONE_PATH,
24
+ MANIFEST_FILE,
25
+ CONFIG_SCHEMA_FILE,
26
+ PROFILE_ID_RE,
27
+ bundledProfileDir,
28
+ userProfileDir,
29
+ promptFileFor,
30
+ } from "./paths.js";
31
+ import { schemaDefaults } from "./manifest.js";
32
+
33
+ function readJson(file) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(file, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function readDirIds(dir) {
42
+ if (!fs.existsSync(dir)) return [];
43
+ return fs
44
+ .readdirSync(dir, { withFileTypes: true })
45
+ .filter((e) => e.isDirectory() && PROFILE_ID_RE.test(e.name))
46
+ .map((e) => e.name)
47
+ .sort();
48
+ }
49
+
50
+ // --------------------- tombstones -------------------------------------------
51
+
52
+ export function readProfileTombstones() {
53
+ const raw = readJson(PROFILES_TOMBSTONE_PATH);
54
+ return new Set(Array.isArray(raw?.ids) ? raw.ids : []);
55
+ }
56
+
57
+ export function writeProfileTombstones(ids) {
58
+ fs.mkdirSync(PROFILES_DIR, { recursive: true });
59
+ fs.writeFileSync(
60
+ PROFILES_TOMBSTONE_PATH,
61
+ JSON.stringify({ ids: [...ids].sort() }, null, 2) + "\n"
62
+ );
63
+ }
64
+
65
+ // --------------------- resolution -------------------------------------------
66
+
67
+ /**
68
+ * Where a profile's files actually come from, honouring the layering.
69
+ * @returns {{ dir: string, source: "user"|"user-override"|"bundled" }|null}
70
+ */
71
+ export function resolveProfileDir(id) {
72
+ if (!PROFILE_ID_RE.test(String(id || ""))) return null;
73
+
74
+ const user = userProfileDir(id);
75
+ const bundled = bundledProfileDir(id);
76
+ const hasUser = fs.existsSync(path.join(user, MANIFEST_FILE));
77
+ const hasBundled = fs.existsSync(path.join(bundled, MANIFEST_FILE));
78
+
79
+ if (hasUser) return { dir: user, source: hasBundled ? "user-override" : "user" };
80
+ if (hasBundled) return { dir: bundled, source: "bundled" };
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Load one profile package.
86
+ * @returns {{
87
+ * id, dir, source, manifest, schema, defaults, prompts: string[]
88
+ * }|null}
89
+ */
90
+ export function readProfile(id) {
91
+ const resolved = resolveProfileDir(id);
92
+ if (!resolved) return null;
93
+
94
+ const manifest = readJson(path.join(resolved.dir, MANIFEST_FILE));
95
+ if (!manifest) return null;
96
+
97
+ const schema = readJson(path.join(resolved.dir, CONFIG_SCHEMA_FILE));
98
+ const prompts = fs.existsSync(resolved.dir)
99
+ ? fs.readdirSync(resolved.dir).filter((f) => /^PROFILE(\.[\w-]+)?\.md$/.test(f)).sort()
100
+ : [];
101
+
102
+ return {
103
+ id,
104
+ dir: resolved.dir,
105
+ source: resolved.source,
106
+ manifest: { ...manifest, id: manifest.id || id },
107
+ schema,
108
+ defaults: schemaDefaults(schema),
109
+ prompts,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Every profile visible to the user: bundled ∪ user, user wins, tombstones
115
+ * filtered out.
116
+ */
117
+ export function listProfiles({ includeRemoved = false } = {}) {
118
+ const tombstones = readProfileTombstones();
119
+ const ids = new Set([...readDirIds(BUNDLED_PROFILES_DIR), ...readDirIds(PROFILES_DIR)]);
120
+
121
+ const out = [];
122
+ for (const id of [...ids].sort()) {
123
+ if (!includeRemoved && tombstones.has(id)) continue;
124
+ const profile = readProfile(id);
125
+ if (profile) out.push({ ...profile, removed: tombstones.has(id) });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Resolve the prompt file for a language, falling back to the base PROFILE.md.
132
+ * Returns the file path, or null when the package has no prompt at all.
133
+ */
134
+ export function resolvePromptFile(profileDir, lang) {
135
+ const candidates = [promptFileFor(lang)];
136
+
137
+ // "pt-BR" → also try "pt" before giving up on the base file.
138
+ const base = String(lang || "").split("-")[0];
139
+ if (base && base !== lang) candidates.push(promptFileFor(base));
140
+ candidates.push(promptFileFor("en"));
141
+
142
+ for (const name of candidates) {
143
+ const file = path.join(profileDir, name);
144
+ if (fs.existsSync(file)) return file;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ // --------------------- active profile state ---------------------------------
150
+
151
+ /**
152
+ * The activation record from global config. Shape:
153
+ * { active: string|null, config: object, installed_at: string, version: string }
154
+ *
155
+ * A missing key, or `active: null`, means vanilla — and vanilla is the default
156
+ * of a clean install.
157
+ */
158
+ export function readProfileState(globalConfig) {
159
+ const p = globalConfig?.profile;
160
+ if (!p || typeof p !== "object") return { active: null, config: {} };
161
+ return {
162
+ active: p.active || null,
163
+ config: p.config && typeof p.config === "object" ? p.config : {},
164
+ installed_at: p.installed_at || null,
165
+ version: p.version || null,
166
+ };
167
+ }
168
+
169
+ /** The profile package that is currently active, or null. */
170
+ export function readActiveProfile(globalConfig) {
171
+ const { active } = readProfileState(globalConfig);
172
+ if (!active) return null;
173
+ return readProfile(active);
174
+ }
175
+
176
+ /**
177
+ * Effective settings for a profile: schema defaults with the user's saved
178
+ * values layered on top. Callers render prompts from this, never from the raw
179
+ * saved config — a package that gains a new setting must not leave a hole.
180
+ */
181
+ export function effectiveProfileConfig(profile, globalConfig) {
182
+ const saved = readProfileState(globalConfig).config || {};
183
+ return { ...(profile?.defaults || {}), ...saved };
184
+ }
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: apx-profile
3
+ description: Agent profiles — installable lines of work for the super-agent (secretary, project manager, analyst, tutor). Load when the user wants to install, activate, configure, diagnose or remove one, or asks why the agent behaves the way it does. Triggers: 'install a profile', 'apx profile', 'what profiles are there', 'activate the secretary', 'go back to vanilla', 'change my agent's schedule', 'why does it message me'.
4
+ ---
5
+
6
+ # apx-profile
7
+
8
+ A **profile** is an installable package that gives the super-agent a line of work: a
9
+ prompt block, its own routines, and the white-label settings the owner fills in. With no
10
+ profile active APX is *vanilla* — the system prompt is byte-identical to a clean install.
11
+
12
+ Three words that are easy to confuse. Keep them apart:
13
+
14
+ | Term | What it is | Where it lives |
15
+ |---|---|---|
16
+ | **profile** | an installable line of work (this skill) | `~/.apx/profiles/`, `config.profile` |
17
+ | **persona** | the super-agent's visible NAME | `~/.apx/identity.json` → `agent_name` |
18
+ | project config | per-project overrides | `.apc/config.json` |
19
+
20
+ Installing and activating are **different operations**. `install` validates a package and
21
+ makes it reachable; `use` is the moment behaviour changes.
22
+
23
+ ## Concrete CLI calls
24
+
25
+ ```bash
26
+ # Discover
27
+ apx profile list # everything available, and which is active
28
+ apx profile show secretary # settings, token cost, where it came from
29
+ apx profile show secretary --preview # ...plus the rendered prompt block
30
+
31
+ # Install and activate
32
+ apx profile install secretary # a bundled id
33
+ apx profile install ./my-profile # a local package directory
34
+ apx profile use secretary # activates + installs its routines
35
+ apx profile use tutor --force # replace whatever is active
36
+
37
+ # Configure — this is where white-label happens
38
+ apx profile config # show current settings
39
+ apx profile config --set day_open_at="30 8 * * 1-5"
40
+ apx profile config --set nudge_budget_per_day=3 --set quiet_hours=22:00-07:30
41
+ apx profile config --interactive # walk the whole schema
42
+
43
+ # Health and removal
44
+ apx profile doctor # what's missing for it to do its job
45
+ apx profile off # back to vanilla
46
+ apx profile uninstall secretary
47
+ ```
48
+
49
+ ## What each command actually does
50
+
51
+ - **`install`** validates the manifest, the schema and every template, then seeds the
52
+ settings with the schema defaults. It does **not** activate. A **local path** is copied
53
+ into `~/.apx/profiles/`; a **bundled** package is not — it is read in place so a later
54
+ `npm update` improves it instead of being shadowed by a stale copy.
55
+ - **`use`** writes `config.profile.active`, reloads the prompt, and installs the package's
56
+ routines (named `<profile-id>-<routine>`, marked `origin: "profile:<id>"`).
57
+ - **`off`** sets `active: null` and **disables** those routines. It deletes nothing —
58
+ settings, tasks, commitments and memory all survive, so `use` again restores everything.
59
+ - **`config`** validates against the schema and **really reschedules**: changing an opening
60
+ time moves the cron, it doesn't just edit JSON.
61
+ - **`uninstall`** removes the package and the routines it installed, but **keeps any routine
62
+ the user edited** and never touches one the user wrote. A bundled package can't be
63
+ deleted, so it gets a tombstone and can be reinstalled any time.
64
+
65
+ ## Settings are per profile
66
+
67
+ `config.profile.configs[<id>]` holds each profile's own settings; `config.profile.config`
68
+ mirrors the active one. Switching A → B → A gives A its settings back rather than handing
69
+ it B's.
70
+
71
+ ## When the user asks "why did it message me?"
72
+
73
+ Read the active profile's prompt block — `apx profile show <id> --preview` — and its
74
+ settings. Interruption budgets, quiet hours and staleness thresholds are all profile
75
+ settings, not core behaviour. If the answer is "it shouldn't have", the fix is usually
76
+ `apx profile config`, not a code change.
77
+
78
+ ## Package layout
79
+
80
+ ```
81
+ <id>/
82
+ profile.json # manifest: id, name, version, requires, prompt_budget_tokens
83
+ PROFILE.md # the always-on prompt block (template)
84
+ PROFILE.es.md # optional translations: PROFILE.<lang>.md
85
+ config.schema.json # the white-label settings, every one with a default
86
+ channels/<ch>.md # optional per-channel overlay, appended after the core file
87
+ routines/*.json # routines it installs
88
+ agents/*.md # specialists it adds to the vault
89
+ skills/<slug>/SKILL.md # its own operational procedures
90
+ ```
91
+
92
+ **Template rules, enforced at install time** (installation fails, naming the variable):
93
+
94
+ - Only flat `{{single_word}}` names. `{{profile.name}}` cannot be substituted and is rejected.
95
+ - Every variable must resolve: a built-in (`owner_name`, `agent_name`, `owner_context`,
96
+ `profile_name`) or a schema property **with a default**. A property declared without a
97
+ default is rejected, because it would silently render as an empty string.
98
+
99
+ ## Channel overlays
100
+
101
+ `channels/<ch>.md` is rendered and appended after the core `channels/<ch>.md`, only on that
102
+ surface. Use it for judgement that must load deterministically where a decision is taken —
103
+ the rules for speaking unprompted belong in `channels/routine.md`, not in an on-demand
104
+ skill, because "should I interrupt?" is a decision the model may not know it is about to
105
+ take. Costs nothing on the channels that don't need it.
106
+
107
+ ## The prompt budget is real
108
+
109
+ The block ships on **every turn of every channel**, on top of a ~2.5k-token base. A
110
+ package declares `prompt_budget_tokens`; exceeding it warns, exceeding 1.5× fails to
111
+ install. Check the real number with `apx profile show <id>` or
112
+ `node scripts/inspect-channel-prompts.js`.
113
+
114
+ ## HTTP
115
+
116
+ `GET /profiles` · `GET /profiles/:id` (includes `preview`) · `GET /profiles/doctor` ·
117
+ `POST /profiles/install` · `POST /profiles/use` · `POST /profiles/off` ·
118
+ `PATCH /profiles/config` · `DELETE /profiles/:id`
119
+
120
+ ## Gotchas
121
+
122
+ - **`install` does not activate.** The most common confusion. Follow it with `use`.
123
+ - **One profile at a time.** Activating a second needs `--force`.
124
+ - **`off` is not `uninstall`.** `off` is reversible and keeps everything.
125
+ - **The vanilla invariant is load-bearing.** With no profile active the prompt must stay
126
+ byte-identical. If a change would alter that, it's a bug, not a feature.
@@ -138,7 +138,7 @@ export function getRoutine(projectPath, name) {
138
138
  return readFile(projectPath).find((r) => r.name === name) || null;
139
139
  }
140
140
 
141
- export function upsertRoutine(storagePath, { name, kind, schedule, spec, enabled = true, permission_mode, allowed_tools, pre_commands, post_commands, skip_prompt_on }) {
141
+ export function upsertRoutine(storagePath, { name, kind, schedule, spec, enabled = true, permission_mode, allowed_tools, pre_commands, post_commands, skip_prompt_on, origin, origin_hash }) {
142
142
  if (!name || !kind || !schedule) throw new Error("routine requires name, kind, schedule");
143
143
  const now = nowIso();
144
144
  const routines = readFile(storagePath);
@@ -166,6 +166,14 @@ export function upsertRoutine(storagePath, { name, kind, schedule, spec, enabled
166
166
  // "always" — never run the LLM (shell-only routine)
167
167
  // "never" — always run the LLM regardless of pre_commands
168
168
  skip_prompt_on: skip_prompt_on || prev?.skip_prompt_on || "signal",
169
+ // Provenance. A routine installed by a persona package carries
170
+ // origin: "persona:<id>" so it can be disabled or removed with that
171
+ // package without touching the user's own routines. `origin_hash` is the
172
+ // hash of the spec as the package rendered it: when the record no longer
173
+ // matches, the user has edited it and the package must never overwrite or
174
+ // delete it again.
175
+ origin: origin ?? prev?.origin ?? null,
176
+ origin_hash: origin_hash ?? prev?.origin_hash ?? null,
169
177
  enabled: enabled !== false,
170
178
  last_run_at: prev?.last_run_at ?? null,
171
179
  last_status: prev?.last_status ?? null,