@agentprojectcontext/apx 1.74.1 → 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 (36) 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 +61 -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-DUXlrW8P.js → index-CXeqTvfy.js} +186 -181
  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/package-lock.json +15 -15
  29. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +245 -0
  30. package/src/interfaces/web/src/hooks/useProfiles.ts +37 -0
  31. package/src/interfaces/web/src/i18n/en.ts +31 -0
  32. package/src/interfaces/web/src/i18n/es.ts +31 -0
  33. package/src/interfaces/web/src/lib/api/profiles.ts +86 -0
  34. package/src/interfaces/web/src/screens/SettingsScreen.tsx +6 -2
  35. package/src/interfaces/web/dist/assets/index-COrRuBp1.css +0 -1
  36. package/src/interfaces/web/dist/assets/index-DUXlrW8P.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.
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { CronExpressionParser } from "cron-parser";
6
6
  import { nowIso, isoToMs } from "../util/time.js";
7
+ import { shortId } from "../util/ids.js";
7
8
 
8
9
  function routinesPath(storagePath) {
9
10
  // storagePath is always ~/.apx/projects/{apxId}/ — flat, no .apc subdir needed.
@@ -82,17 +83,62 @@ export function computeNextRun(routine, baseMs = Date.now()) {
82
83
  return null;
83
84
  }
84
85
 
86
+ // --------------------- ids + migration --------------------------------------
87
+
88
+ // Routines are addressed by `name` everywhere (getRoutine/deleteRoutine/
89
+ // setEnabled/updateRunState), but per-routine memory is keyed by `id`
90
+ // (stores/routine-memory.js). Records written before ids existed have none, so
91
+ // every one of them resolved to the shared `routines/_unknown/memory.md`.
92
+ //
93
+ // This migration is deliberately NOT inside readFile(): that helper is on the
94
+ // scheduler's 5s polling path, and writing from it would mean write
95
+ // amplification plus a read-modify-write race against a concurrent CLI edit.
96
+ // Instead the public read entry points call this, and it early-returns without
97
+ // touching disk once every record has an id — so the write happens once per
98
+ // project, ever.
99
+
100
+ /**
101
+ * Backfill `id` on any routine record that predates the field.
102
+ * Returns the number of records migrated (0 when there was nothing to do).
103
+ */
104
+ export function ensureRoutineIds(storagePath) {
105
+ const routines = readFile(storagePath);
106
+ const missing = routines.filter((r) => r && !r.id);
107
+ if (missing.length === 0) return 0;
108
+
109
+ for (const r of missing) r.id = shortId("r");
110
+ writeFile(storagePath, routines);
111
+
112
+ // Anything already written under routines/_unknown/ belonged to an
113
+ // indeterminate set of routines — we can't know which, so we leave it where
114
+ // it is rather than guess, and say so out loud once.
115
+ const orphan = path.join(storagePath, "routines", "_unknown");
116
+ if (fs.existsSync(orphan)) {
117
+ // eslint-disable-next-line no-console
118
+ console.warn(
119
+ `[apx] routines: assigned ids to ${missing.length} routine(s). Pre-existing shared\n` +
120
+ ` memory at ${orphan} was left untouched — it cannot be attributed to a single\n` +
121
+ ` routine. Copy anything worth keeping into the per-routine memory files.`
122
+ );
123
+ }
124
+ return missing.length;
125
+ }
126
+
85
127
  // --------------------- CRUD -------------------------------------------------
86
128
 
87
129
  export function listRoutines(projectPath) {
130
+ ensureRoutineIds(projectPath);
88
131
  return readFile(projectPath);
89
132
  }
90
133
 
91
134
  export function getRoutine(projectPath, name) {
135
+ // Callers hand the record straight to the runner, which needs `id` for
136
+ // per-routine memory.
137
+ ensureRoutineIds(projectPath);
92
138
  return readFile(projectPath).find((r) => r.name === name) || null;
93
139
  }
94
140
 
95
- 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 }) {
96
142
  if (!name || !kind || !schedule) throw new Error("routine requires name, kind, schedule");
97
143
  const now = nowIso();
98
144
  const routines = readFile(storagePath);
@@ -100,6 +146,10 @@ export function upsertRoutine(storagePath, { name, kind, schedule, spec, enabled
100
146
  const prev = idx >= 0 ? routines[idx] : null;
101
147
  const next = computeNextRun({ schedule, last_run_at: null });
102
148
  const entry = {
149
+ // `entry` is rebuilt from scratch on every upsert, so the id MUST be
150
+ // carried over explicitly (same as created_at below). Dropping it here
151
+ // would re-id the routine on every edit and orphan its memory directory.
152
+ id: prev?.id || shortId("r"),
103
153
  name,
104
154
  kind,
105
155
  schedule,
@@ -116,6 +166,14 @@ export function upsertRoutine(storagePath, { name, kind, schedule, spec, enabled
116
166
  // "always" — never run the LLM (shell-only routine)
117
167
  // "never" — always run the LLM regardless of pre_commands
118
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,
119
177
  enabled: enabled !== false,
120
178
  last_run_at: prev?.last_run_at ?? null,
121
179
  last_status: prev?.last_status ?? null,
@@ -167,6 +225,8 @@ export function updateRunState(projectPath, name, { last_run_at, last_status, la
167
225
  }
168
226
 
169
227
  export function getDueRoutines(projectPath, nowStr) {
228
+ // The runner keys per-routine memory off `id`, so due records must carry one.
229
+ ensureRoutineIds(projectPath);
170
230
  return readFile(projectPath).filter((r) => {
171
231
  if (!r.enabled) return false;
172
232
  // CRITICAL: If the schedule cannot be parsed, NEVER run it.