@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.
- package/package.json +1 -1
- package/src/core/agent/prompt-builder.js +26 -7
- package/src/core/agent/render-template.js +22 -0
- package/src/core/profiles/block.js +290 -0
- package/src/core/profiles/bundled/secretary/PROFILE.es.md +44 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +44 -0
- package/src/core/profiles/bundled/secretary/channels/routine.md +43 -0
- package/src/core/profiles/bundled/secretary/config.schema.json +49 -0
- package/src/core/profiles/bundled/secretary/profile.json +20 -0
- package/src/core/profiles/bundled/secretary/routines/day-close.json +10 -0
- package/src/core/profiles/bundled/secretary/routines/day-open.json +10 -0
- package/src/core/profiles/index.js +16 -0
- package/src/core/profiles/lifecycle.js +720 -0
- package/src/core/profiles/manifest.js +193 -0
- package/src/core/profiles/paths.js +51 -0
- package/src/core/profiles/store.js +184 -0
- package/src/core/runtime-skills/apx-profile/SKILL.md +126 -0
- package/src/core/stores/routines.js +61 -1
- package/src/host/daemon/api/profiles.js +179 -0
- package/src/host/daemon/api/web.js +1 -1
- package/src/host/daemon/api.js +2 -0
- package/src/interfaces/cli/commands/profile.js +252 -0
- package/src/interfaces/cli/index.js +62 -0
- package/src/interfaces/web/dist/assets/index-CQ5kyFej.css +1 -0
- package/src/interfaces/web/dist/assets/{index-DUXlrW8P.js → index-CXeqTvfy.js} +186 -181
- package/src/interfaces/web/dist/assets/index-CXeqTvfy.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +15 -15
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +245 -0
- package/src/interfaces/web/src/hooks/useProfiles.ts +37 -0
- package/src/interfaces/web/src/i18n/en.ts +31 -0
- package/src/interfaces/web/src/i18n/es.ts +31 -0
- package/src/interfaces/web/src/lib/api/profiles.ts +86 -0
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +6 -2
- package/src/interfaces/web/dist/assets/index-COrRuBp1.css +0 -1
- 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.
|