@monster-orange/den 0.4.11 → 0.4.13
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/CHANGELOG.md +169 -0
- package/README.md +7 -7
- package/docs/distributions.md +212 -12
- package/package.json +2 -1
- package/src/shared/agents/catalog.ts +77 -0
- package/src/shared/agents/default-pack/be-dev.md +1 -0
- package/src/shared/agents/default-pack/devops.md +1 -0
- package/src/shared/agents/default-pack/fe-dev.md +1 -0
- package/src/shared/agents/default-pack/orchestrator.md +3 -1
- package/src/shared/agents/default-pack/planner.md +1 -0
- package/src/shared/agents/default-pack/reviewer.md +1 -0
- package/src/shared/agents/default-pack/tester.md +16 -0
- package/src/shared/agents/default-pack/ux-designer.md +1 -0
- package/src/shared/agents/den-claude.ts +151 -17
- package/src/shared/agents/permissions.ts +108 -0
- package/src/shared/agents/persona-store.ts +286 -0
- package/src/shared/agents/personas.ts +116 -12
- package/src/shared/agents/records.ts +57 -0
- package/src/shared/agents/roles.ts +288 -0
- package/src/shared/agents/runtime.ts +49 -0
- package/src/shared/agents/session-name.ts +110 -0
- package/src/shared/agents/transcript-follow.ts +180 -0
- package/src/shared/den-config.ts +27 -1
- package/src/ui/cli/agent-ops.ts +578 -11
- package/src/ui/cli/agents-command.ts +12 -2
- package/src/ui/cli/env-probe.ts +4 -1
- package/src/ui/mcp/tools.ts +1 -1
- package/src/ui/server/agent-paths.ts +52 -0
- package/src/ui/server/agent-routes.ts +329 -19
- package/src/ui/server/agents-layout-pref.ts +43 -0
- package/src/ui/server/editor-open-pref.ts +41 -0
- package/src/ui/server/index.ts +22 -0
- package/src/ui/server/public/agent-start.js +3 -2
- package/src/ui/server/public/agents-page.js +1037 -67
- package/src/ui/server/public/agents-section.js +1 -1
- package/src/ui/server/public/agents.html +236 -20
- package/src/ui/server/public/header.js +2 -0
- package/src/ui/server/public/i18n/es.js +139 -0
- package/src/ui/server/public/i18n/ja.js +139 -0
- package/src/ui/server/public/persona-ui.css +539 -0
- package/src/ui/server/public/persona-ui.js +796 -0
- package/src/ui/server/public/settings-page.js +4 -9
- package/src/ui/server/public/settings-registry.js +66 -0
- package/src/ui/server/public/settings.html +1 -0
- package/src/ui/server/public/shell.js +137 -0
- package/src/ui/server/public/term-input.js +129 -0
- package/src/ui/server/public/topbar.js +22 -4
- package/src/ui/server/route-classes.ts +29 -0
- package/src/ui/server/session-routes.ts +34 -1
- package/src/ui/server/ui-assets.ts +8 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's own personas (AG-10.3, AG-10.7): <state_dir>/personas/<id>.md,
|
|
3
|
+
* each 0600 in a 0700 directory, written in the same format a pack uses
|
|
4
|
+
* (Claude Code's agent markdown plus Den's keys: personas.ts), so a file
|
|
5
|
+
* made here reads like one made by hand.
|
|
6
|
+
*
|
|
7
|
+
* Only these files are ever written or removed: a built-in or a
|
|
8
|
+
* distribution's persona is read-only, and editing one makes a local
|
|
9
|
+
* duplicate first (the caller's choice: the routes do). Every field is
|
|
10
|
+
* checked strictly (checkPersonaInput): an unknown key, a control
|
|
11
|
+
* character, a role, category, runtime, model or version that isn't
|
|
12
|
+
* registered, or a command that isn't a plain prefix is refused, naming
|
|
13
|
+
* the field. Nothing here runs a command or passes text to a shell.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import * as yaml from "yaml";
|
|
18
|
+
import { writePrivate } from "./records.js";
|
|
19
|
+
import { PERSONA_ID_RE } from "./personas.js";
|
|
20
|
+
import {
|
|
21
|
+
checkCommands,
|
|
22
|
+
checkIntent,
|
|
23
|
+
type PermissionIntent,
|
|
24
|
+
} from "./permissions.js";
|
|
25
|
+
import type { RuntimeModel } from "./runtime.js";
|
|
26
|
+
|
|
27
|
+
/** Where a person's own personas live. */
|
|
28
|
+
export function localPersonasDir(stateDir: string): string {
|
|
29
|
+
return path.join(stateDir, "personas");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What New persona and Edit send, checked. */
|
|
33
|
+
export interface PersonaInput {
|
|
34
|
+
displayName?: string;
|
|
35
|
+
role: string;
|
|
36
|
+
category: string;
|
|
37
|
+
description: string;
|
|
38
|
+
instructions: string;
|
|
39
|
+
runtime: string;
|
|
40
|
+
model?: string;
|
|
41
|
+
version: string;
|
|
42
|
+
permission: PermissionIntent;
|
|
43
|
+
allowedCommands: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a field may name: the registries and runtimes it is checked against. */
|
|
47
|
+
export interface PersonaChoices {
|
|
48
|
+
roles: readonly string[];
|
|
49
|
+
categories: readonly string[];
|
|
50
|
+
runtimes: readonly { id: string; models: readonly RuntimeModel[] }[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const MAX_INSTRUCTIONS = 20_000;
|
|
54
|
+
export const MAX_DESCRIPTION = 200;
|
|
55
|
+
export const MAX_PERSONA_NAME = 40;
|
|
56
|
+
|
|
57
|
+
const KEYS = [
|
|
58
|
+
"displayName",
|
|
59
|
+
"role",
|
|
60
|
+
"category",
|
|
61
|
+
"description",
|
|
62
|
+
"instructions",
|
|
63
|
+
"runtime",
|
|
64
|
+
"model",
|
|
65
|
+
"version",
|
|
66
|
+
"permission",
|
|
67
|
+
"allowedCommands",
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
/** A usage error: a field a person sent isn't what it may be. */
|
|
71
|
+
export class PersonaInputError extends Error {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "PersonaInputError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const shown = (v: unknown) =>
|
|
79
|
+
(JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
|
|
80
|
+
|
|
81
|
+
function oneLine(
|
|
82
|
+
key: string,
|
|
83
|
+
v: unknown,
|
|
84
|
+
max: number,
|
|
85
|
+
required: boolean,
|
|
86
|
+
): string | undefined {
|
|
87
|
+
if (v === undefined || v === null || v === "") {
|
|
88
|
+
if (required) throw new PersonaInputError(`${key} is required`);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (typeof v !== "string")
|
|
92
|
+
throw new PersonaInputError(`${key} must be a string`);
|
|
93
|
+
const s = v.trim();
|
|
94
|
+
if (!s) {
|
|
95
|
+
if (required) throw new PersonaInputError(`${key} is required`);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
if ([...s].length > max)
|
|
99
|
+
throw new PersonaInputError(`${key} must be at most ${max} characters`);
|
|
100
|
+
if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(s))
|
|
101
|
+
throw new PersonaInputError(`${key} can't have control characters`);
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function oneOf(key: string, v: unknown, list: readonly string[]): string {
|
|
106
|
+
if (typeof v !== "string" || !list.includes(v))
|
|
107
|
+
throw new PersonaInputError(
|
|
108
|
+
`${key}: ${shown(v)} is not one of ${list.join(", ") || "(none)"}`,
|
|
109
|
+
);
|
|
110
|
+
return v;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A model and version for runtime `rt` (AG-12.3): the model one it offers
|
|
115
|
+
* (none: no model), the version "latest" or one of that model's.
|
|
116
|
+
*/
|
|
117
|
+
export function checkModelChoice(
|
|
118
|
+
rt: { id: string; models: readonly RuntimeModel[] },
|
|
119
|
+
model: unknown,
|
|
120
|
+
version: unknown,
|
|
121
|
+
): { model?: string; version: string } {
|
|
122
|
+
const ver =
|
|
123
|
+
version === undefined || version === null || version === ""
|
|
124
|
+
? "latest"
|
|
125
|
+
: version;
|
|
126
|
+
if (model === undefined || model === null || model === "") {
|
|
127
|
+
if (ver !== "latest") throw new PersonaInputError("version needs a model");
|
|
128
|
+
return { version: "latest" };
|
|
129
|
+
}
|
|
130
|
+
if (!rt.models.length)
|
|
131
|
+
throw new PersonaInputError(`${rt.id} takes no model choice from Den`);
|
|
132
|
+
const m = rt.models.find((x) => x.id === model);
|
|
133
|
+
if (!m)
|
|
134
|
+
throw new PersonaInputError(
|
|
135
|
+
`model: ${shown(model)} is not one of ${rt.id}'s (${rt.models.map((x) => x.id).join(", ")})`,
|
|
136
|
+
);
|
|
137
|
+
if (
|
|
138
|
+
ver !== "latest" &&
|
|
139
|
+
!(typeof ver === "string" && m.versions.includes(ver))
|
|
140
|
+
)
|
|
141
|
+
throw new PersonaInputError(
|
|
142
|
+
`version: ${shown(ver)} is not "latest" or one of ${m.id}'s (${m.versions.join(", ")})`,
|
|
143
|
+
);
|
|
144
|
+
return { model: m.id, version: ver as string };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A persona as New persona or Edit sends it, checked against `choices`.
|
|
149
|
+
* Throws PersonaInputError naming the first field that is wrong.
|
|
150
|
+
*/
|
|
151
|
+
export function checkPersonaInput(
|
|
152
|
+
raw: unknown,
|
|
153
|
+
choices: PersonaChoices,
|
|
154
|
+
): PersonaInput {
|
|
155
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
156
|
+
throw new PersonaInputError("a persona must be a JSON object");
|
|
157
|
+
const r = raw as Record<string, unknown>;
|
|
158
|
+
for (const k of Object.keys(r))
|
|
159
|
+
if (!(KEYS as readonly string[]).includes(k))
|
|
160
|
+
throw new PersonaInputError(
|
|
161
|
+
`${k}: unknown field (a persona takes ${KEYS.join(", ")})`,
|
|
162
|
+
);
|
|
163
|
+
const role = oneOf("role", r.role, choices.roles);
|
|
164
|
+
const category = oneOf("category", r.category, choices.categories);
|
|
165
|
+
const runtime = oneOf(
|
|
166
|
+
"runtime",
|
|
167
|
+
r.runtime,
|
|
168
|
+
choices.runtimes.map((x) => x.id),
|
|
169
|
+
);
|
|
170
|
+
const rt = choices.runtimes.find((x) => x.id === runtime)!;
|
|
171
|
+
const { model, version } = checkModelChoice(rt, r.model, r.version);
|
|
172
|
+
let permission: PermissionIntent;
|
|
173
|
+
let allowedCommands: string[];
|
|
174
|
+
try {
|
|
175
|
+
permission = checkIntent(r.permission ?? "ask", "permission");
|
|
176
|
+
allowedCommands = checkCommands(r.allowedCommands, "allowedCommands");
|
|
177
|
+
} catch (e) {
|
|
178
|
+
throw new PersonaInputError((e as Error).message);
|
|
179
|
+
}
|
|
180
|
+
const instructions = r.instructions ?? "";
|
|
181
|
+
if (typeof instructions !== "string")
|
|
182
|
+
throw new PersonaInputError("instructions must be a string");
|
|
183
|
+
const text = instructions.replace(/\r\n?/g, "\n").trim();
|
|
184
|
+
if (!text) throw new PersonaInputError("instructions are required");
|
|
185
|
+
if (text.length > MAX_INSTRUCTIONS)
|
|
186
|
+
throw new PersonaInputError(
|
|
187
|
+
`instructions must be at most ${MAX_INSTRUCTIONS} characters`,
|
|
188
|
+
);
|
|
189
|
+
// Newlines and tabs only: no escape sequences, no bidi overrides.
|
|
190
|
+
// eslint-disable-next-line no-control-regex
|
|
191
|
+
if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(text))
|
|
192
|
+
throw new PersonaInputError("instructions can't have control characters");
|
|
193
|
+
const displayName = oneLine(
|
|
194
|
+
"displayName",
|
|
195
|
+
r.displayName,
|
|
196
|
+
MAX_PERSONA_NAME,
|
|
197
|
+
false,
|
|
198
|
+
);
|
|
199
|
+
return {
|
|
200
|
+
...(displayName ? { displayName } : {}),
|
|
201
|
+
role,
|
|
202
|
+
category,
|
|
203
|
+
description: oneLine("description", r.description, MAX_DESCRIPTION, true)!,
|
|
204
|
+
instructions: text,
|
|
205
|
+
runtime,
|
|
206
|
+
...(model ? { model } : {}),
|
|
207
|
+
version,
|
|
208
|
+
permission,
|
|
209
|
+
allowedCommands,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The persona file for `input`: front matter (YAML) and its instructions. */
|
|
214
|
+
export function personaMarkdown(id: string, input: PersonaInput): string {
|
|
215
|
+
const fm: Record<string, unknown> = {
|
|
216
|
+
name: id,
|
|
217
|
+
...(input.displayName ? { display_name: input.displayName } : {}),
|
|
218
|
+
role: input.role,
|
|
219
|
+
category: input.category,
|
|
220
|
+
description: input.description,
|
|
221
|
+
runtime: input.runtime,
|
|
222
|
+
...(input.model ? { model: input.model } : {}),
|
|
223
|
+
version: input.version,
|
|
224
|
+
permission_mode: input.permission,
|
|
225
|
+
...(input.allowedCommands.length
|
|
226
|
+
? { allowed_commands: input.allowedCommands }
|
|
227
|
+
: {}),
|
|
228
|
+
};
|
|
229
|
+
return `---\n${yaml.stringify(fm).trimEnd()}\n---\n\n${input.instructions}\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function fileOf(stateDir: string, id: string): string {
|
|
233
|
+
if (!PERSONA_ID_RE.test(id))
|
|
234
|
+
throw new PersonaInputError(`"${shown(id)}" is not a persona id`);
|
|
235
|
+
return path.join(localPersonasDir(stateDir), `${id}.md`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether a local persona file `id` exists. */
|
|
239
|
+
export function localPersonaExists(stateDir: string, id: string): boolean {
|
|
240
|
+
return PERSONA_ID_RE.test(id) && fs.existsSync(fileOf(stateDir, id));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Write local persona `id` (0600, atomically). */
|
|
244
|
+
export function writeLocalPersona(
|
|
245
|
+
stateDir: string,
|
|
246
|
+
id: string,
|
|
247
|
+
input: PersonaInput,
|
|
248
|
+
): string {
|
|
249
|
+
const file = fileOf(stateDir, id);
|
|
250
|
+
writePrivate(file, personaMarkdown(id, input));
|
|
251
|
+
return file;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Remove local persona `id`; false when there was none. Only that file. */
|
|
255
|
+
export function deleteLocalPersona(stateDir: string, id: string): boolean {
|
|
256
|
+
const file = fileOf(stateDir, id);
|
|
257
|
+
try {
|
|
258
|
+
fs.unlinkSync(file);
|
|
259
|
+
return true;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A new persona's id: from its name (else its role), lowercased, made of
|
|
268
|
+
* what an id may hold; "-2", "-3", … when taken.
|
|
269
|
+
*/
|
|
270
|
+
export function newPersonaId(
|
|
271
|
+
input: Pick<PersonaInput, "displayName" | "role">,
|
|
272
|
+
taken: ReadonlySet<string>,
|
|
273
|
+
): string {
|
|
274
|
+
const base =
|
|
275
|
+
(input.displayName ?? input.role)
|
|
276
|
+
.toLowerCase()
|
|
277
|
+
.normalize("NFKD")
|
|
278
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
279
|
+
.replace(/^[^a-z0-9]+|-+$/g, "")
|
|
280
|
+
.slice(0, 56) || "persona";
|
|
281
|
+
if (!taken.has(base)) return base;
|
|
282
|
+
for (let i = 2; ; i++) {
|
|
283
|
+
const id = `${base}-${i}`;
|
|
284
|
+
if (!taken.has(id)) return id;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -12,20 +12,35 @@
|
|
|
12
12
|
* model the default model; `inherit` means none of its own
|
|
13
13
|
* tools "Read, Grep" or a list: hints, never a restriction Den applies
|
|
14
14
|
*
|
|
15
|
-
* Den's own keys sit beside them: `
|
|
15
|
+
* Den's own keys sit beside them (AG-10.2): `display_name` ("Rita", shown
|
|
16
|
+
* as "Rita (reviewer)"), `role` (a key into the role registry, roles.ts:
|
|
17
|
+
* planner, sde, …; a sentence there, as before AG-10, is read as the
|
|
18
|
+
* description), `category` (engineering, design, …; default from the
|
|
19
|
+
* role), `version` ("latest" or a pinned model version),
|
|
20
|
+
* `allowed_commands` (plain command prefixes run without asking: AG-13.4),
|
|
21
|
+
* `runtime` (default den-claude),
|
|
16
22
|
* `standing` (starts with its workspace, §5; resumes after a restart),
|
|
17
23
|
* `resume_on_restart` (AG-14.3: resumes after a restart too), `context` (what to read
|
|
18
24
|
* first), `argv` (the generic runtime's template), and `permission_mode`
|
|
19
|
-
* (
|
|
25
|
+
* (Den's intent: ask, edits or all; or a Claude Code mode, which
|
|
26
|
+
* den-claude checks), and
|
|
20
27
|
* `den_hooks` / `den_instructions` (true or false: this persona's
|
|
21
28
|
* guardrails, guardrails.ts, over its pack's). Any other key (a
|
|
22
29
|
* Claude Code `color`) is ignored. A file without front matter (a pack's
|
|
23
30
|
* README) is not a persona.
|
|
31
|
+
*
|
|
32
|
+
* Where personas come from (AG-10.3), each with its origin: built-in
|
|
33
|
+
* (Den's default pack), distribution (agents.persona_packs) and local
|
|
34
|
+
* (<state_dir>/personas/*.md, made in the UI or by hand: persona-store.ts).
|
|
35
|
+
* Of two with one id, local wins over distribution, and distribution over
|
|
36
|
+
* built-in. Hub comes later; only its origin value exists now.
|
|
24
37
|
*/
|
|
25
38
|
import * as fs from "fs";
|
|
26
39
|
import * as path from "path";
|
|
27
40
|
import * as yaml from "yaml";
|
|
28
41
|
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
42
|
+
import { checkCommands } from "./permissions.js";
|
|
43
|
+
import { DEN_ROLES, REGISTRY_ID_RE } from "./roles.js";
|
|
29
44
|
import { enginePath } from "../engine.js";
|
|
30
45
|
import {
|
|
31
46
|
DEFAULT_PACK_GUARDRAILS,
|
|
@@ -34,10 +49,31 @@ import {
|
|
|
34
49
|
type Guardrails,
|
|
35
50
|
} from "./guardrails.js";
|
|
36
51
|
|
|
52
|
+
/** Where a persona came from (AG-10.3). */
|
|
53
|
+
export type PersonaOrigin = "builtin" | "distribution" | "local" | "hub";
|
|
54
|
+
export const PERSONA_ORIGINS: readonly PersonaOrigin[] = [
|
|
55
|
+
"builtin",
|
|
56
|
+
"distribution",
|
|
57
|
+
"local",
|
|
58
|
+
"hub",
|
|
59
|
+
];
|
|
60
|
+
|
|
37
61
|
export interface Persona {
|
|
38
62
|
id: string;
|
|
63
|
+
/** A person's name for it ("Rita"), shown as "Rita (reviewer)". */
|
|
64
|
+
displayName?: string;
|
|
65
|
+
/** Its role: a key into the role registry (roles.ts), when it has one. */
|
|
66
|
+
role?: string;
|
|
39
67
|
/** One line: what it does. */
|
|
40
|
-
|
|
68
|
+
description: string;
|
|
69
|
+
/** Its category (roles.ts): the role's, else engineering. */
|
|
70
|
+
category: string;
|
|
71
|
+
/** "latest" (default) or a pinned version of its model. */
|
|
72
|
+
version?: string;
|
|
73
|
+
/** Plain command prefixes it may run without asking (AG-13.4). */
|
|
74
|
+
allowedCommands: string[];
|
|
75
|
+
/** Where it came from (loadPersonas sets it). */
|
|
76
|
+
origin: PersonaOrigin;
|
|
41
77
|
/** The role prompt: the file's body. */
|
|
42
78
|
prompt: string;
|
|
43
79
|
/** The runtime it runs on unless an agent says otherwise. */
|
|
@@ -70,6 +106,9 @@ export interface Persona {
|
|
|
70
106
|
source: string;
|
|
71
107
|
}
|
|
72
108
|
|
|
109
|
+
/** A model version as a persona pins it: "latest" or a version id. */
|
|
110
|
+
export const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
|
|
111
|
+
|
|
73
112
|
/** A persona id: the same rules as an agent id or a workspace name. */
|
|
74
113
|
export const PERSONA_ID_RE = WORKSPACE_NAME_RE;
|
|
75
114
|
|
|
@@ -115,7 +154,11 @@ function stringList(
|
|
|
115
154
|
}
|
|
116
155
|
|
|
117
156
|
/** One persona file, or null when it has no front matter. */
|
|
118
|
-
export function parsePersona(
|
|
157
|
+
export function parsePersona(
|
|
158
|
+
text: string,
|
|
159
|
+
file: string,
|
|
160
|
+
origin: PersonaOrigin = "builtin",
|
|
161
|
+
): Persona | null {
|
|
119
162
|
const m = FRONT_MATTER.exec(text);
|
|
120
163
|
if (!m) return null;
|
|
121
164
|
let fm: unknown;
|
|
@@ -155,6 +198,35 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
155
198
|
argv = f.argv as string[];
|
|
156
199
|
}
|
|
157
200
|
const model = optString(f, "model", file);
|
|
201
|
+
const displayName = optString(f, "display_name", file);
|
|
202
|
+
if (
|
|
203
|
+
displayName &&
|
|
204
|
+
([...displayName].length > 40 ||
|
|
205
|
+
/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(displayName))
|
|
206
|
+
)
|
|
207
|
+
throw new Error(
|
|
208
|
+
`${file}: display_name must be at most 40 characters, with no control characters`,
|
|
209
|
+
);
|
|
210
|
+
// AG-10.2: `role` is a role key; a sentence there (before AG-10) is the
|
|
211
|
+
// description it always was.
|
|
212
|
+
const roleRaw = optString(f, "role", file);
|
|
213
|
+
const role = roleRaw && REGISTRY_ID_RE.test(roleRaw) ? roleRaw : undefined;
|
|
214
|
+
const category = optString(f, "category", file);
|
|
215
|
+
if (category && !REGISTRY_ID_RE.test(category))
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${file}: category "${category.slice(0, 40)}" must be lowercase letters, digits and "-"`,
|
|
218
|
+
);
|
|
219
|
+
const version = optString(f, "version", file);
|
|
220
|
+
if (version && !VERSION_RE.test(version))
|
|
221
|
+
throw new Error(
|
|
222
|
+
`${file}: version must be "latest" or a version id (letters, digits, . _ @ : -)`,
|
|
223
|
+
);
|
|
224
|
+
let allowedCommands: string[];
|
|
225
|
+
try {
|
|
226
|
+
allowedCommands = checkCommands(f.allowed_commands, "allowed_commands");
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new Error(`${file}: ${(e as Error).message}`);
|
|
229
|
+
}
|
|
158
230
|
const runtime = optString(f, "runtime", file);
|
|
159
231
|
const permissionMode = optString(f, "permission_mode", file);
|
|
160
232
|
const guardrailsSet: Partial<Guardrails> = {};
|
|
@@ -170,7 +242,19 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
170
242
|
}
|
|
171
243
|
return {
|
|
172
244
|
id,
|
|
173
|
-
|
|
245
|
+
...(displayName ? { displayName } : {}),
|
|
246
|
+
...(role ? { role } : {}),
|
|
247
|
+
description:
|
|
248
|
+
(roleRaw && !role ? roleRaw : undefined) ??
|
|
249
|
+
optString(f, "description", file) ??
|
|
250
|
+
"",
|
|
251
|
+
category:
|
|
252
|
+
category ??
|
|
253
|
+
DEN_ROLES.find((r) => r.id === role)?.category ??
|
|
254
|
+
"engineering",
|
|
255
|
+
...(version && version !== "latest" ? { version } : {}),
|
|
256
|
+
allowedCommands,
|
|
257
|
+
origin,
|
|
174
258
|
prompt: text.slice(m[0].length).trim(),
|
|
175
259
|
...(runtime ? { runtime } : {}),
|
|
176
260
|
...(model && model !== "inherit" ? { model } : {}),
|
|
@@ -189,7 +273,10 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
189
273
|
* A pack's personas, by file name. A directory with an `agents/` inside (a
|
|
190
274
|
* Claude Code plugin) is read from there.
|
|
191
275
|
*/
|
|
192
|
-
export function readPack(
|
|
276
|
+
export function readPack(
|
|
277
|
+
dir: string,
|
|
278
|
+
origin: PersonaOrigin = "builtin",
|
|
279
|
+
): Persona[] {
|
|
193
280
|
const agents = path.join(dir, "agents");
|
|
194
281
|
const from = fs.statSync(agents, { throwIfNoEntry: false })?.isDirectory()
|
|
195
282
|
? agents
|
|
@@ -200,7 +287,7 @@ export function readPack(dir: string): Persona[] {
|
|
|
200
287
|
if (!name.endsWith(".md")) continue;
|
|
201
288
|
const file = path.join(from, name);
|
|
202
289
|
if (!fs.statSync(file).isFile()) continue;
|
|
203
|
-
const p = parsePersona(fs.readFileSync(file, "utf-8"), file);
|
|
290
|
+
const p = parsePersona(fs.readFileSync(file, "utf-8"), file, origin);
|
|
204
291
|
if (!p) continue;
|
|
205
292
|
const prev = seen.get(p.id);
|
|
206
293
|
if (prev) throw new Error(`${prev} and ${file} both define "${p.id}"`);
|
|
@@ -237,17 +324,34 @@ export function loadPersonas(
|
|
|
237
324
|
packDirs: string[],
|
|
238
325
|
defaultPack: string = DEFAULT_PACK_DIR,
|
|
239
326
|
guardrails: PackGuardrails = {},
|
|
327
|
+
/**
|
|
328
|
+
* <state_dir>/personas (AG-10.3): a person's own, read last so one wins
|
|
329
|
+
* over a pack's of the same id. Absent: none. They get the default
|
|
330
|
+
* pack's guardrails (Den's hooks and instructions) unless their own
|
|
331
|
+
* front matter says otherwise.
|
|
332
|
+
*/
|
|
333
|
+
localDir?: string,
|
|
240
334
|
): Map<string, Persona> {
|
|
241
335
|
const all = new Map<string, Persona>();
|
|
242
|
-
const packs: [string, Guardrails][] = [
|
|
243
|
-
[
|
|
244
|
-
|
|
336
|
+
const packs: [string, Guardrails, PersonaOrigin][] = [
|
|
337
|
+
[
|
|
338
|
+
defaultPack,
|
|
339
|
+
{ ...DEFAULT_PACK_GUARDRAILS, ...guardrails.defaultPack },
|
|
340
|
+
"builtin",
|
|
341
|
+
],
|
|
342
|
+
...packDirs.map((dir, i): [string, Guardrails, PersonaOrigin] => [
|
|
245
343
|
dir,
|
|
246
344
|
{ ...PACK_GUARDRAILS, ...guardrails.packs?.[i] },
|
|
345
|
+
"distribution",
|
|
247
346
|
]),
|
|
248
347
|
];
|
|
249
|
-
|
|
250
|
-
|
|
348
|
+
if (
|
|
349
|
+
localDir &&
|
|
350
|
+
fs.statSync(localDir, { throwIfNoEntry: false })?.isDirectory()
|
|
351
|
+
)
|
|
352
|
+
packs.push([localDir, { ...DEFAULT_PACK_GUARDRAILS }, "local"]);
|
|
353
|
+
for (const [dir, g, origin] of packs)
|
|
354
|
+
for (const p of readPack(dir, origin))
|
|
251
355
|
all.set(p.id, { ...p, guardrails: { ...g, ...p.guardrailsSet } });
|
|
252
356
|
return all;
|
|
253
357
|
}
|
|
@@ -21,6 +21,7 @@ import * as path from "path";
|
|
|
21
21
|
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
22
22
|
import type { SessionRef } from "../sessions/types.js";
|
|
23
23
|
import type { RotationEntry } from "./rotation.js";
|
|
24
|
+
import type { PermissionIntent } from "./permissions.js";
|
|
24
25
|
|
|
25
26
|
export interface AgentRecord {
|
|
26
27
|
id: string;
|
|
@@ -72,6 +73,62 @@ export interface AgentRecord {
|
|
|
72
73
|
* rotation), oldest first.
|
|
73
74
|
*/
|
|
74
75
|
rotations?: RotationEntry[];
|
|
76
|
+
/**
|
|
77
|
+
* The name a person gave it (AG-4b: double-click its tab), shown in the
|
|
78
|
+
* Agents tab, its tabs, ⌘K and the agent lists; the id never changes.
|
|
79
|
+
* checkDisplayName says what one may be.
|
|
80
|
+
*/
|
|
81
|
+
displayName?: string;
|
|
82
|
+
/** When displayName was given (ISO 8601): the latest rename wins (AG-4b). */
|
|
83
|
+
displayNameAt?: string;
|
|
84
|
+
/**
|
|
85
|
+
* The model version it runs (AG-12.3): "latest" (the model's newest), or
|
|
86
|
+
* a pinned version. Absent: before AG-12, `model` as given.
|
|
87
|
+
*/
|
|
88
|
+
version?: string;
|
|
89
|
+
/** The permission intent it was started with (AG-13), when one was set. */
|
|
90
|
+
permission?: PermissionIntent;
|
|
91
|
+
/**
|
|
92
|
+
* The extra allowed commands it was started with (AG-13.4: plain
|
|
93
|
+
* prefixes; its role's preset comes on top), when set for it.
|
|
94
|
+
*/
|
|
95
|
+
allow?: string[];
|
|
96
|
+
/**
|
|
97
|
+
* What it was started with that differs from its persona (AG-10.9):
|
|
98
|
+
* the list tags it. Start fresh and Relaunch keep the choices.
|
|
99
|
+
*/
|
|
100
|
+
overrides?: Override[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A setting a session can override for one launch (AG-10.9). */
|
|
104
|
+
export type Override = "runtime" | "model" | "version" | "permission" | "allow";
|
|
105
|
+
export const OVERRIDES: readonly Override[] = [
|
|
106
|
+
"runtime",
|
|
107
|
+
"model",
|
|
108
|
+
"version",
|
|
109
|
+
"permission",
|
|
110
|
+
"allow",
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/** The longest name a person may give an agent (AG-4b). */
|
|
114
|
+
export const MAX_DISPLAY_NAME = 40;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A name given to an agent, checked (AG-4b): a string, trimmed, at most
|
|
118
|
+
* MAX_DISPLAY_NAME characters, with no control or format characters (no
|
|
119
|
+
* newline, no bidi override). Blank (or null) clears it: null. Anything
|
|
120
|
+
* else throws, saying why.
|
|
121
|
+
*/
|
|
122
|
+
export function checkDisplayName(v: unknown): string | null {
|
|
123
|
+
if (v === null) return null;
|
|
124
|
+
if (typeof v !== "string") throw new Error("name must be a string, or null");
|
|
125
|
+
const name = v.trim();
|
|
126
|
+
if (!name) return null;
|
|
127
|
+
if ([...name].length > MAX_DISPLAY_NAME)
|
|
128
|
+
throw new Error(`name must be at most ${MAX_DISPLAY_NAME} characters`);
|
|
129
|
+
if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(name))
|
|
130
|
+
throw new Error("name can't have control characters");
|
|
131
|
+
return name;
|
|
75
132
|
}
|
|
76
133
|
|
|
77
134
|
/** What an agent's run came to (den#6). Read locally; never sent. */
|