@gkzhb/pi-roles 0.2.4-beta.1
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 +70 -0
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/dist/index.d.ts +291 -0
- package/dist/index.js +957 -0
- package/examples/architect.md +30 -0
- package/examples/orchestrator.md +29 -0
- package/package.json +93 -0
- package/resources/roles/role-assistant.md +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
// src/schemas.ts
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
var ThinkingLevelSchema = Type.Union(
|
|
4
|
+
[
|
|
5
|
+
Type.Literal("off"),
|
|
6
|
+
Type.Literal("minimal"),
|
|
7
|
+
Type.Literal("low"),
|
|
8
|
+
Type.Literal("medium"),
|
|
9
|
+
Type.Literal("high"),
|
|
10
|
+
Type.Literal("xhigh")
|
|
11
|
+
],
|
|
12
|
+
{ description: "Reasoning effort level. 'off' for non-reasoning models." }
|
|
13
|
+
);
|
|
14
|
+
var IntercomModeSchema = Type.Union(
|
|
15
|
+
[
|
|
16
|
+
Type.Literal("off"),
|
|
17
|
+
Type.Literal("receive"),
|
|
18
|
+
Type.Literal("send"),
|
|
19
|
+
Type.Literal("both")
|
|
20
|
+
],
|
|
21
|
+
{ description: "Per-role intercom mode. Defaults to global intercomMode setting when omitted." }
|
|
22
|
+
);
|
|
23
|
+
var RoleScopeSchema = Type.Union(
|
|
24
|
+
[Type.Literal("user"), Type.Literal("project"), Type.Literal("both")],
|
|
25
|
+
{ description: "Which scopes to search when discovering roles." }
|
|
26
|
+
);
|
|
27
|
+
var RoleSourceSchema = Type.Union(
|
|
28
|
+
[Type.Literal("project"), Type.Literal("user"), Type.Literal("built-in")],
|
|
29
|
+
{ description: "Origin of a discovered role file." }
|
|
30
|
+
);
|
|
31
|
+
var RoleFrontmatterSchema = Type.Object(
|
|
32
|
+
{
|
|
33
|
+
/** Unique identifier; must match the filename without `.md`. */
|
|
34
|
+
name: Type.String({ minLength: 1, description: "Role identifier; matches the filename." }),
|
|
35
|
+
/** One-line description shown in /role list and pickers. */
|
|
36
|
+
description: Type.String({
|
|
37
|
+
minLength: 1,
|
|
38
|
+
description: "Short human-readable description of what the role is for."
|
|
39
|
+
}),
|
|
40
|
+
/**
|
|
41
|
+
* Model identifier in `provider/id` or `id` form. Resolved against Pi's
|
|
42
|
+
* model registry at apply time. If the model isn't available, we warn
|
|
43
|
+
* and keep the session's current model.
|
|
44
|
+
*/
|
|
45
|
+
model: Type.Optional(Type.String({ description: "Model id; e.g. 'anthropic/claude-opus-4-7'." })),
|
|
46
|
+
/** Reasoning level. Clamped to model capabilities by Pi. */
|
|
47
|
+
thinking: Type.Optional(ThinkingLevelSchema),
|
|
48
|
+
/**
|
|
49
|
+
* Tool list as a raw, comma-separated string. Empty string means "no
|
|
50
|
+
* tools". Use the `mcp:server-name` syntax for MCP tools (requires
|
|
51
|
+
* pi-mcp-adapter at runtime). Parse and tri-state semantics live in
|
|
52
|
+
* `roles.ts`. We accept `null` because YAML's `tools:` (no value)
|
|
53
|
+
* deserializes to that.
|
|
54
|
+
*/
|
|
55
|
+
tools: Type.Optional(
|
|
56
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
57
|
+
description: "Comma-separated tool names. Empty/null = no tools. Use mcp:server-name for MCP tools."
|
|
58
|
+
})
|
|
59
|
+
),
|
|
60
|
+
/** Per-role intercom mode override. Falls back to global `intercomMode`. */
|
|
61
|
+
intercom: Type.Optional(IntercomModeSchema),
|
|
62
|
+
/**
|
|
63
|
+
* Name of a parent role to inherit from. Resolved against the same scope
|
|
64
|
+
* the child was loaded from, then user, then built-in. Cycles are a hard
|
|
65
|
+
* error.
|
|
66
|
+
*/
|
|
67
|
+
extends: Type.Optional(
|
|
68
|
+
Type.String({ minLength: 1, description: "Parent role name to inherit from." })
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
additionalProperties: true,
|
|
73
|
+
description: "Role frontmatter. Unknown fields are tolerated for forward compatibility; check warnings."
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
var PiRolesSettingsSchema = Type.Object(
|
|
77
|
+
{
|
|
78
|
+
/** "user" | "project" | "both". Default: "both". */
|
|
79
|
+
roleScope: Type.Optional(RoleScopeSchema),
|
|
80
|
+
/**
|
|
81
|
+
* Default role name applied when no --role / PI_ROLE is supplied.
|
|
82
|
+
* Default: "role-assistant" (the built-in fallback).
|
|
83
|
+
* If set to a missing role, we warn and use the built-in role-assistant.
|
|
84
|
+
*/
|
|
85
|
+
defaultRole: Type.Optional(Type.String({ minLength: 1 })),
|
|
86
|
+
/**
|
|
87
|
+
* Keep the currently active role when creating a normal new conversation.
|
|
88
|
+
* Default: false, so new conversations resolve defaultRole as usual.
|
|
89
|
+
* Does not apply to --reset, reload, resume, or process restarts.
|
|
90
|
+
*/
|
|
91
|
+
preserveRoleOnNewSession: Type.Optional(Type.Boolean()),
|
|
92
|
+
/** Default intercom mode for roles that don't set `intercom:`. Default: "off". */
|
|
93
|
+
intercomMode: Type.Optional(IntercomModeSchema),
|
|
94
|
+
/**
|
|
95
|
+
* Model used to summarize the first user message into the session-name
|
|
96
|
+
* intent. Default: a small/cheap model when one is available, falling
|
|
97
|
+
* back to the session's current model.
|
|
98
|
+
*/
|
|
99
|
+
titleModel: Type.Optional(Type.String({ minLength: 1 })),
|
|
100
|
+
/** Whether to surface a warning when an mcp:* tool can't be resolved. Default: true. */
|
|
101
|
+
warnOnMissingMcp: Type.Optional(Type.Boolean())
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
additionalProperties: true,
|
|
105
|
+
description: "pi-roles section of Pi's settings.json."
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
var ActiveRoleStateSchema = Type.Object(
|
|
109
|
+
{
|
|
110
|
+
name: Type.String(),
|
|
111
|
+
/** Source of the LEAF role file when it was applied. */
|
|
112
|
+
source: RoleSourceSchema,
|
|
113
|
+
/** Path of the LEAF role file. */
|
|
114
|
+
path: Type.String(),
|
|
115
|
+
/** Cached session-intent summary for the title. May be empty pre-first-message. */
|
|
116
|
+
intent: Type.Optional(Type.String()),
|
|
117
|
+
/** Unix ms timestamp; for diagnostics only. */
|
|
118
|
+
appliedAt: Type.Number()
|
|
119
|
+
},
|
|
120
|
+
{ additionalProperties: false }
|
|
121
|
+
);
|
|
122
|
+
var ACTIVE_ROLE_ENTRY_TYPE = "pi-roles:active-role";
|
|
123
|
+
var ROLE_NOTIFICATION_MESSAGE_TYPE = "pi-roles:notification";
|
|
124
|
+
var STATUS_KEY = "pi-roles";
|
|
125
|
+
var BUILTIN_ROLE_ASSISTANT_NAME = "role-assistant";
|
|
126
|
+
var INTENT_PLACEHOLDER = "Intent not defined";
|
|
127
|
+
|
|
128
|
+
// src/debug.ts
|
|
129
|
+
import { appendFileSync } from "fs";
|
|
130
|
+
var ENABLED = process.env.PI_ROLES_DEBUG && process.env.PI_ROLES_DEBUG.length > 0;
|
|
131
|
+
var LOG_PATH = process.env.PI_ROLES_DEBUG_PATH || "/tmp/pi-roles-debug.log";
|
|
132
|
+
function now() {
|
|
133
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
134
|
+
}
|
|
135
|
+
function debugLog(label, message, extra) {
|
|
136
|
+
if (!ENABLED) return;
|
|
137
|
+
try {
|
|
138
|
+
const line = extra !== void 0 ? `[${now()}] [${label}] ${message} | extra=${JSON.stringify(extra)}
|
|
139
|
+
` : `[${now()}] [${label}] ${message}
|
|
140
|
+
`;
|
|
141
|
+
appendFileSync(LOG_PATH, line);
|
|
142
|
+
} catch {
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/apply.ts
|
|
147
|
+
function parseModelId(raw) {
|
|
148
|
+
const idx = raw.indexOf("/");
|
|
149
|
+
if (idx <= 0 || idx === raw.length - 1) return { id: raw };
|
|
150
|
+
return { provider: raw.slice(0, idx), id: raw.slice(idx + 1) };
|
|
151
|
+
}
|
|
152
|
+
function findModelInRegistry(registry, raw) {
|
|
153
|
+
const { provider, id } = parseModelId(raw);
|
|
154
|
+
if (provider) {
|
|
155
|
+
return { model: registry.find(provider, id), ambiguous: false };
|
|
156
|
+
}
|
|
157
|
+
const matches = registry.getAll().filter((m) => m.id === id);
|
|
158
|
+
return { model: matches[0], ambiguous: matches.length > 1 };
|
|
159
|
+
}
|
|
160
|
+
function effectiveIntercomMode(role, globalDefault) {
|
|
161
|
+
return role.intercom ?? globalDefault ?? "off";
|
|
162
|
+
}
|
|
163
|
+
function filterToolsForRuntime(directive, availableToolNames, intercomMode, intercomAvailable, warnOnMissingMcp) {
|
|
164
|
+
const warnings = [];
|
|
165
|
+
if (directive.kind === "inherit") {
|
|
166
|
+
return { kind: "inherit", warnings };
|
|
167
|
+
}
|
|
168
|
+
const kept = [];
|
|
169
|
+
const seen = /* @__PURE__ */ new Set();
|
|
170
|
+
for (const name of directive.names) {
|
|
171
|
+
if (seen.has(name)) continue;
|
|
172
|
+
seen.add(name);
|
|
173
|
+
if (name.startsWith("mcp:")) {
|
|
174
|
+
if (availableToolNames.has(name)) {
|
|
175
|
+
kept.push(name);
|
|
176
|
+
} else if (warnOnMissingMcp) {
|
|
177
|
+
warnings.push(
|
|
178
|
+
`Tool "${name}" is not registered (pi-mcp-adapter may not be installed or the server is not configured). Skipping.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!availableToolNames.has(name)) {
|
|
184
|
+
warnings.push(
|
|
185
|
+
`Tool "${name}" is not registered. Passing through; another extension may register it later.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
kept.push(name);
|
|
189
|
+
}
|
|
190
|
+
if (intercomMode !== "off" && intercomAvailable && !seen.has("intercom")) {
|
|
191
|
+
kept.push("intercom");
|
|
192
|
+
}
|
|
193
|
+
return { kind: "set", names: kept, warnings };
|
|
194
|
+
}
|
|
195
|
+
function composeSessionName(intent, roleName) {
|
|
196
|
+
const trimmed = (intent ?? "").trim();
|
|
197
|
+
const intentPart = trimmed.length > 0 ? trimmed : INTENT_PLACEHOLDER;
|
|
198
|
+
return `${intentPart} - ${roleName}`;
|
|
199
|
+
}
|
|
200
|
+
function composeFooterStatus(roleName, intent) {
|
|
201
|
+
return composeSessionName(intent, roleName);
|
|
202
|
+
}
|
|
203
|
+
async function applyRole(role, applyCtx, options = {}) {
|
|
204
|
+
const { pi, ctx } = applyCtx;
|
|
205
|
+
const warnings = [];
|
|
206
|
+
if (role.model) {
|
|
207
|
+
const lookup = findModelInRegistry(ctx.modelRegistry, role.model);
|
|
208
|
+
if (!lookup.model) {
|
|
209
|
+
debugLog("apply", `model not found: ${role.model}`);
|
|
210
|
+
warnings.push(
|
|
211
|
+
`Model "${role.model}" not found in registry. Keeping current model.`
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
if (lookup.ambiguous) {
|
|
215
|
+
warnings.push(
|
|
216
|
+
`Model id "${role.model}" matches multiple providers; using "${lookup.model.provider}/${lookup.model.id}". Use provider/id to disambiguate.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const ok = await pi.setModel(lookup.model);
|
|
220
|
+
if (!ok) {
|
|
221
|
+
debugLog("apply", `setModel refused: ${lookup.model.provider}/${lookup.model.id}`);
|
|
222
|
+
warnings.push(
|
|
223
|
+
`Model "${lookup.model.provider}/${lookup.model.id}" has no API key configured. Keeping current model.`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (role.thinking !== void 0) {
|
|
229
|
+
pi.setThinkingLevel(role.thinking);
|
|
230
|
+
}
|
|
231
|
+
const intercomMode = effectiveIntercomMode(role, applyCtx.intercomMode);
|
|
232
|
+
const allTools = pi.getAllTools();
|
|
233
|
+
const availableNames = new Set(allTools.map((t) => t.name));
|
|
234
|
+
const intercomAvailable = availableNames.has("intercom");
|
|
235
|
+
const filtered = filterToolsForRuntime(
|
|
236
|
+
role.tools,
|
|
237
|
+
availableNames,
|
|
238
|
+
intercomMode,
|
|
239
|
+
intercomAvailable,
|
|
240
|
+
applyCtx.warnOnMissingMcp
|
|
241
|
+
);
|
|
242
|
+
warnings.push(...filtered.warnings);
|
|
243
|
+
if (filtered.kind === "set") {
|
|
244
|
+
pi.setActiveTools(filtered.names);
|
|
245
|
+
}
|
|
246
|
+
if (intercomMode !== "off" && !intercomAvailable) {
|
|
247
|
+
warnings.push(
|
|
248
|
+
`Role requests intercom mode "${intercomMode}" but the intercom tool is not registered. Install pi-intercom to enable.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (ctx.hasUI) {
|
|
252
|
+
ctx.ui.setStatus(STATUS_KEY, composeFooterStatus(role.name, options.preservedIntent));
|
|
253
|
+
}
|
|
254
|
+
pi.setSessionName(composeSessionName(options.preservedIntent, role.name));
|
|
255
|
+
const state = {
|
|
256
|
+
name: role.name,
|
|
257
|
+
source: role.source,
|
|
258
|
+
path: role.path,
|
|
259
|
+
intent: options.preservedIntent,
|
|
260
|
+
appliedAt: Date.now()
|
|
261
|
+
};
|
|
262
|
+
pi.appendEntry(ACTIVE_ROLE_ENTRY_TYPE, state);
|
|
263
|
+
if (!options.silent) {
|
|
264
|
+
const display = warnings.length === 0 ? `Switched to role ${role.name}` : `Switched to role ${role.name} (${warnings.length} warning${warnings.length === 1 ? "" : "s"})`;
|
|
265
|
+
pi.sendMessage({
|
|
266
|
+
customType: ROLE_NOTIFICATION_MESSAGE_TYPE,
|
|
267
|
+
content: display,
|
|
268
|
+
display: true,
|
|
269
|
+
details: { name: role.name, source: role.source, warnings }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return { warnings, state };
|
|
273
|
+
}
|
|
274
|
+
async function resetSession(ctx) {
|
|
275
|
+
await ctx.waitForIdle();
|
|
276
|
+
return ctx.newSession();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/intercom.ts
|
|
280
|
+
var INTERCOM_TOOL_NAME = "intercom";
|
|
281
|
+
function isIntercomAvailable(pi) {
|
|
282
|
+
return pi.getAllTools().some((t) => t.name === INTERCOM_TOOL_NAME);
|
|
283
|
+
}
|
|
284
|
+
function intercomPromptAddendum(mode, sessionName) {
|
|
285
|
+
if (mode === "off") return "";
|
|
286
|
+
const id = sessionName && sessionName.length > 0 ? sessionName : "(unnamed session)";
|
|
287
|
+
switch (mode) {
|
|
288
|
+
case "receive":
|
|
289
|
+
return [
|
|
290
|
+
"## intercom (receive mode)",
|
|
291
|
+
`This session is targetable as "${id}". Other sessions may send you messages via the \`intercom\` tool. Respond promptly when targeted; do not initiate outbound intercom messages unless the user explicitly asks.`
|
|
292
|
+
].join("\n");
|
|
293
|
+
case "send":
|
|
294
|
+
return [
|
|
295
|
+
"## intercom (send mode)",
|
|
296
|
+
`You may send messages to other pi sessions via the \`intercom\` tool when coordination is needed. Identify yourself as "${id}". Use sparingly \u2014 only when another session genuinely needs the information.`
|
|
297
|
+
].join("\n");
|
|
298
|
+
case "both":
|
|
299
|
+
return [
|
|
300
|
+
"## intercom (both modes)",
|
|
301
|
+
`This session is "${id}". You may send messages to other sessions via the \`intercom\` tool, and you may be targeted by them. Respond promptly when targeted; initiate outbound messages only when coordination is genuinely needed.`
|
|
302
|
+
].join("\n");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/roles.ts
|
|
307
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
308
|
+
import { homedir } from "os";
|
|
309
|
+
import { dirname, join, resolve } from "path";
|
|
310
|
+
import { fileURLToPath } from "url";
|
|
311
|
+
import { parse as parseYaml } from "yaml";
|
|
312
|
+
import { Value } from "typebox/value";
|
|
313
|
+
var RoleResolutionError = class extends Error {
|
|
314
|
+
constructor(message) {
|
|
315
|
+
super(message);
|
|
316
|
+
this.name = "RoleResolutionError";
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
function findProjectRolesDir(start) {
|
|
320
|
+
let dir = resolve(start);
|
|
321
|
+
while (true) {
|
|
322
|
+
const candidate = join(dir, ".pi", "roles");
|
|
323
|
+
if (isDir(candidate)) return candidate;
|
|
324
|
+
const parent = dirname(dir);
|
|
325
|
+
if (parent === dir) return null;
|
|
326
|
+
dir = parent;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function userRolesDir() {
|
|
330
|
+
return join(homedir(), ".pi", "agent", "roles");
|
|
331
|
+
}
|
|
332
|
+
function builtInRolesDir() {
|
|
333
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
334
|
+
return resolve(here, "..", "resources", "roles");
|
|
335
|
+
}
|
|
336
|
+
function isDir(path) {
|
|
337
|
+
try {
|
|
338
|
+
return statSync(path).isDirectory();
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function listRoleFiles(dir) {
|
|
344
|
+
if (!isDir(dir)) return [];
|
|
345
|
+
return readdirSync(dir).filter((name) => name.endsWith(".md") && !name.startsWith(".")).map((name) => join(dir, name));
|
|
346
|
+
}
|
|
347
|
+
function discoverRoles(cwd, scope) {
|
|
348
|
+
const buckets = [];
|
|
349
|
+
if (scope === "project" || scope === "both") {
|
|
350
|
+
const projectDir = findProjectRolesDir(cwd);
|
|
351
|
+
buckets.push({ source: "project", files: projectDir ? listRoleFiles(projectDir) : [] });
|
|
352
|
+
}
|
|
353
|
+
if (scope === "user" || scope === "both") {
|
|
354
|
+
buckets.push({ source: "user", files: listRoleFiles(userRolesDir()) });
|
|
355
|
+
}
|
|
356
|
+
buckets.push({ source: "built-in", files: listRoleFiles(builtInRolesDir()) });
|
|
357
|
+
const roles = [];
|
|
358
|
+
const shadowed = [];
|
|
359
|
+
const seen = /* @__PURE__ */ new Set();
|
|
360
|
+
for (const bucket of buckets) {
|
|
361
|
+
for (const file of bucket.files) {
|
|
362
|
+
const raw = loadRoleFile(file, bucket.source);
|
|
363
|
+
if (seen.has(raw.frontmatter.name)) {
|
|
364
|
+
shadowed.push({ name: raw.frontmatter.name, source: bucket.source, path: file });
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
seen.add(raw.frontmatter.name);
|
|
368
|
+
roles.push(raw);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { roles, shadowed };
|
|
372
|
+
}
|
|
373
|
+
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
374
|
+
function loadRoleFile(path, source) {
|
|
375
|
+
const text = readFileSync(path, "utf8");
|
|
376
|
+
return parseRoleSource(text, path, source);
|
|
377
|
+
}
|
|
378
|
+
function parseRoleSource(text, path, source) {
|
|
379
|
+
const match = FRONTMATTER_RE.exec(text);
|
|
380
|
+
if (!match) {
|
|
381
|
+
throw new RoleResolutionError(
|
|
382
|
+
`${path}: missing or malformed frontmatter. Expected '---' delimited YAML at the top of the file.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
const yamlBlock = match[1] ?? "";
|
|
386
|
+
const body = (match[2] ?? "").trim();
|
|
387
|
+
let parsed;
|
|
388
|
+
try {
|
|
389
|
+
parsed = parseYaml(yamlBlock);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
392
|
+
debugLog("roles", `YAML parse error in ${path}`, detail);
|
|
393
|
+
throw new RoleResolutionError(`${path}: invalid YAML in frontmatter \u2014 ${detail}`);
|
|
394
|
+
}
|
|
395
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
396
|
+
throw new RoleResolutionError(`${path}: frontmatter must be a YAML mapping.`);
|
|
397
|
+
}
|
|
398
|
+
if (!Value.Check(RoleFrontmatterSchema, parsed)) {
|
|
399
|
+
const errors = [...Value.Errors(RoleFrontmatterSchema, parsed)];
|
|
400
|
+
const first = errors[0];
|
|
401
|
+
const where = first ? first.instancePath || "(root)" : "(root)";
|
|
402
|
+
const why = first ? first.message : "schema validation failed";
|
|
403
|
+
throw new RoleResolutionError(`${path}: invalid frontmatter at ${where} \u2014 ${why}`);
|
|
404
|
+
}
|
|
405
|
+
const frontmatter = parsed;
|
|
406
|
+
const expectedName = basenameWithoutExt(path);
|
|
407
|
+
if (frontmatter.name !== expectedName) {
|
|
408
|
+
throw new RoleResolutionError(
|
|
409
|
+
`${path}: frontmatter 'name' is "${frontmatter.name}" but filename implies "${expectedName}". They must match.`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return { source, path, frontmatter, body };
|
|
413
|
+
}
|
|
414
|
+
function basenameWithoutExt(path) {
|
|
415
|
+
const base = path.split(/[\\/]/).pop() ?? path;
|
|
416
|
+
return base.replace(/\.md$/, "");
|
|
417
|
+
}
|
|
418
|
+
function normalizeTools(value) {
|
|
419
|
+
if (value === void 0) return { kind: "inherit" };
|
|
420
|
+
if (value === null) return { kind: "set", names: [] };
|
|
421
|
+
const names = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
422
|
+
return { kind: "set", names };
|
|
423
|
+
}
|
|
424
|
+
function resolveRole(name, all) {
|
|
425
|
+
const byName = /* @__PURE__ */ new Map();
|
|
426
|
+
for (const r of all) byName.set(r.frontmatter.name, r);
|
|
427
|
+
const leaf = byName.get(name);
|
|
428
|
+
if (!leaf) {
|
|
429
|
+
debugLog("roles", `role not found: ${name}`);
|
|
430
|
+
throw new RoleResolutionError(
|
|
431
|
+
`Role "${name}" not found. Run /role list to see available roles.`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const chain = [];
|
|
435
|
+
const seen = /* @__PURE__ */ new Set();
|
|
436
|
+
let cursor = leaf;
|
|
437
|
+
while (cursor) {
|
|
438
|
+
if (seen.has(cursor.frontmatter.name)) {
|
|
439
|
+
const cyclePath = [...chain.map((r) => r.frontmatter.name), cursor.frontmatter.name];
|
|
440
|
+
throw new RoleResolutionError(
|
|
441
|
+
`Cycle detected in 'extends' chain: ${cyclePath.join(" -> ")}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
seen.add(cursor.frontmatter.name);
|
|
445
|
+
chain.push(cursor);
|
|
446
|
+
const parentName = cursor.frontmatter.extends;
|
|
447
|
+
if (!parentName) break;
|
|
448
|
+
const parent = byName.get(parentName);
|
|
449
|
+
if (!parent) {
|
|
450
|
+
debugLog("roles", `extends not found: ${cursor.frontmatter.name} -> ${parentName}`);
|
|
451
|
+
throw new RoleResolutionError(
|
|
452
|
+
`Role "${cursor.frontmatter.name}" extends "${parentName}", but no such role was found.`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
cursor = parent;
|
|
456
|
+
}
|
|
457
|
+
const ordered = [...chain].reverse();
|
|
458
|
+
let description;
|
|
459
|
+
let model;
|
|
460
|
+
let thinking;
|
|
461
|
+
let intercom;
|
|
462
|
+
let tools = { kind: "inherit" };
|
|
463
|
+
const bodies = [];
|
|
464
|
+
for (const role of ordered) {
|
|
465
|
+
const fm = role.frontmatter;
|
|
466
|
+
if (fm.description) description = fm.description;
|
|
467
|
+
if (fm.model !== void 0) model = fm.model;
|
|
468
|
+
if (fm.thinking !== void 0) thinking = fm.thinking;
|
|
469
|
+
if (fm.intercom !== void 0) intercom = fm.intercom;
|
|
470
|
+
const directive = normalizeTools(fm.tools);
|
|
471
|
+
if (directive.kind === "set") tools = directive;
|
|
472
|
+
if (role.body.length > 0) bodies.push(role.body);
|
|
473
|
+
}
|
|
474
|
+
const finalDescription = description ?? leaf.frontmatter.description;
|
|
475
|
+
return {
|
|
476
|
+
name: leaf.frontmatter.name,
|
|
477
|
+
description: finalDescription,
|
|
478
|
+
model,
|
|
479
|
+
thinking,
|
|
480
|
+
tools,
|
|
481
|
+
intercom,
|
|
482
|
+
body: bodies.join("\n\n---\n\n"),
|
|
483
|
+
source: leaf.source,
|
|
484
|
+
path: leaf.path,
|
|
485
|
+
// chain[] is leaf-first per the field contract.
|
|
486
|
+
extendsChain: chain.map((r) => r.frontmatter.name)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function findBuiltInAssistant(roles) {
|
|
490
|
+
return roles.find(
|
|
491
|
+
(r) => r.frontmatter.name === BUILTIN_ROLE_ASSISTANT_NAME && r.source === "built-in"
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/settings.ts
|
|
496
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
497
|
+
import { homedir as homedir2 } from "os";
|
|
498
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
499
|
+
import { Value as Value2 } from "typebox/value";
|
|
500
|
+
var NAMESPACE = "pi-roles";
|
|
501
|
+
function findProjectSettingsFile(start) {
|
|
502
|
+
let dir = resolve2(start);
|
|
503
|
+
while (true) {
|
|
504
|
+
const candidate = join2(dir, ".pi", "settings.json");
|
|
505
|
+
if (existsSync(candidate)) return candidate;
|
|
506
|
+
const parent = dirname2(dir);
|
|
507
|
+
if (parent === dir) return null;
|
|
508
|
+
dir = parent;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function userSettingsFile() {
|
|
512
|
+
return join2(homedir2(), ".pi", "agent", "settings.json");
|
|
513
|
+
}
|
|
514
|
+
function readNamespace(path) {
|
|
515
|
+
if (!path || !existsSync(path)) return {};
|
|
516
|
+
let parsed;
|
|
517
|
+
try {
|
|
518
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
519
|
+
} catch {
|
|
520
|
+
debugLog("settings", `failed to parse ${path}`);
|
|
521
|
+
return {};
|
|
522
|
+
}
|
|
523
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
524
|
+
const raw = parsed[NAMESPACE];
|
|
525
|
+
if (!raw || typeof raw !== "object") return {};
|
|
526
|
+
if (!Value2.Check(PiRolesSettingsSchema, raw)) return {};
|
|
527
|
+
return raw;
|
|
528
|
+
}
|
|
529
|
+
function loadSettings(cwd) {
|
|
530
|
+
const user = readNamespace(userSettingsFile());
|
|
531
|
+
const project = readNamespace(findProjectSettingsFile(cwd));
|
|
532
|
+
return { ...user, ...project };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/title.ts
|
|
536
|
+
import {
|
|
537
|
+
complete
|
|
538
|
+
} from "@mariozechner/pi-ai";
|
|
539
|
+
var TITLE_SYSTEM_PROMPT = [
|
|
540
|
+
"You generate a short session title from the user's first message in a chat session.",
|
|
541
|
+
"",
|
|
542
|
+
"Rules:",
|
|
543
|
+
"- 5 to 10 words. Capture the user's intent \u2014 what they want done.",
|
|
544
|
+
"- Use noun phrases or short imperatives.",
|
|
545
|
+
'- No quotes. No prefixes like "Title:". No trailing punctuation. No newlines.',
|
|
546
|
+
'- Be specific. Prefer "Debug websocket reconnect bug" over "Debug a bug".',
|
|
547
|
+
"- Output ONLY the title text. No explanation."
|
|
548
|
+
].join("\n");
|
|
549
|
+
var MAX_WORDS = 10;
|
|
550
|
+
function extractTitle(raw) {
|
|
551
|
+
if (!raw) return "";
|
|
552
|
+
const firstLine = raw.split(/\r?\n/).find((l) => l.trim().length > 0);
|
|
553
|
+
if (!firstLine) return "";
|
|
554
|
+
let s = firstLine.trim();
|
|
555
|
+
const quotePairs = [
|
|
556
|
+
['"', '"'],
|
|
557
|
+
["'", "'"],
|
|
558
|
+
["\u201C", "\u201D"],
|
|
559
|
+
["\u2018", "\u2019"],
|
|
560
|
+
["`", "`"]
|
|
561
|
+
];
|
|
562
|
+
for (const [open, close] of quotePairs) {
|
|
563
|
+
if (s.length > open.length + close.length && s.startsWith(open) && s.endsWith(close)) {
|
|
564
|
+
s = s.slice(open.length, s.length - close.length).trim();
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
s = s.replace(/[.!?,;]+$/u, "").trim();
|
|
569
|
+
s = s.replace(/\s+/g, " ");
|
|
570
|
+
const words = s.split(" ");
|
|
571
|
+
if (words.length > MAX_WORDS) {
|
|
572
|
+
s = words.slice(0, MAX_WORDS).join(" ");
|
|
573
|
+
s = s.replace(/[.!?,;]+$/u, "").trim();
|
|
574
|
+
}
|
|
575
|
+
return s;
|
|
576
|
+
}
|
|
577
|
+
function extractTitleFromMessage(message) {
|
|
578
|
+
const text = message.content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
|
|
579
|
+
return extractTitle(text);
|
|
580
|
+
}
|
|
581
|
+
function resolveTitleModel(ctx, configured) {
|
|
582
|
+
if (configured && configured.length > 0) {
|
|
583
|
+
const lookup = findModelInRegistry(ctx.modelRegistry, configured);
|
|
584
|
+
if (lookup.model) return lookup.model;
|
|
585
|
+
}
|
|
586
|
+
return ctx.model;
|
|
587
|
+
}
|
|
588
|
+
async function generateAndApplyTitle(args) {
|
|
589
|
+
const { prompt, state, pi, ctx, configuredTitleModel } = args;
|
|
590
|
+
const completeFn = args.completeFn ?? complete;
|
|
591
|
+
debugLog("title", "generateAndApplyTitle entered", {
|
|
592
|
+
hasIntent: !!state.intent,
|
|
593
|
+
inFlight: state.titleInFlight,
|
|
594
|
+
hasActiveRole: !!state.activeRole,
|
|
595
|
+
promptLen: prompt?.length ?? 0,
|
|
596
|
+
configuredTitleModel,
|
|
597
|
+
ctxModelId: ctx?.model?.id
|
|
598
|
+
});
|
|
599
|
+
if (state.intent) {
|
|
600
|
+
debugLog("title", "guard: intent already set");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (state.titleInFlight) {
|
|
604
|
+
debugLog("title", "guard: titleInFlight");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (!state.activeRole) {
|
|
608
|
+
debugLog("title", "guard: no activeRole");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const trimmed = prompt.trim();
|
|
612
|
+
if (!trimmed) {
|
|
613
|
+
debugLog("title", "guard: empty prompt");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const model = resolveTitleModel(ctx, configuredTitleModel);
|
|
617
|
+
if (!model) {
|
|
618
|
+
debugLog("title", "guard: no model resolved");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
state.titleInFlight = true;
|
|
622
|
+
try {
|
|
623
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
624
|
+
if (!auth.ok) {
|
|
625
|
+
debugLog("title", "title model authentication unavailable", { error: auth.error });
|
|
626
|
+
if (!state.titleErrorShown && ctx.hasUI) {
|
|
627
|
+
ctx.ui.notify(`pi-roles: title model authentication failed (${auth.error}).`, "warning");
|
|
628
|
+
state.titleErrorShown = true;
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
debugLog("title", "calling complete", {
|
|
633
|
+
modelId: model?.id,
|
|
634
|
+
modelProvider: model?.provider ?? model?.api?.provider,
|
|
635
|
+
configuredTitleModel,
|
|
636
|
+
usedFallback: !configuredTitleModel || configuredTitleModel.length === 0,
|
|
637
|
+
hasApiKey: !!auth.apiKey,
|
|
638
|
+
headerNames: auth.headers ? Object.keys(auth.headers) : [],
|
|
639
|
+
promptLen: trimmed.length
|
|
640
|
+
});
|
|
641
|
+
const message = await completeFn(
|
|
642
|
+
model,
|
|
643
|
+
{
|
|
644
|
+
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
645
|
+
messages: [{ role: "user", content: trimmed, timestamp: Date.now() }]
|
|
646
|
+
},
|
|
647
|
+
// `complete` does not consult Pi's ModelRegistry. Passing resolved
|
|
648
|
+
// auth here is essential for custom providers whose API key comes from
|
|
649
|
+
// a models.json env reference rather than pi-ai's built-in env map.
|
|
650
|
+
{ apiKey: auth.apiKey, headers: auth.headers }
|
|
651
|
+
);
|
|
652
|
+
debugLog("title", "complete returned", {
|
|
653
|
+
stopReason: message?.stopReason,
|
|
654
|
+
errorMessage: message?.errorMessage,
|
|
655
|
+
usage: message?.usage,
|
|
656
|
+
contentBlockCount: message?.content?.length ?? 0,
|
|
657
|
+
contentBlockTypes: message?.content?.map((b) => b.type),
|
|
658
|
+
fullMessageKeys: message ? Object.keys(message) : [],
|
|
659
|
+
fullMessage: message
|
|
660
|
+
});
|
|
661
|
+
const stopReason = message?.stopReason;
|
|
662
|
+
if (stopReason === "error") {
|
|
663
|
+
const errMsg = message?.errorMessage ?? "unknown error";
|
|
664
|
+
debugLog("title", "complete stopReason=error", { errorMessage: errMsg });
|
|
665
|
+
if (!state.titleErrorShown && ctx.hasUI) {
|
|
666
|
+
ctx.ui.notify(
|
|
667
|
+
`pi-roles: title model failed (${errMsg}). Set settings.titleModel to a model with credentials.`,
|
|
668
|
+
"warning"
|
|
669
|
+
);
|
|
670
|
+
state.titleErrorShown = true;
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const intent = extractTitleFromMessage(message);
|
|
675
|
+
if (!intent) return;
|
|
676
|
+
if (state.intent) return;
|
|
677
|
+
if (!state.activeRole) return;
|
|
678
|
+
state.intent = intent;
|
|
679
|
+
pi.setSessionName(composeSessionName(intent, state.activeRole.name));
|
|
680
|
+
if (ctx.hasUI) {
|
|
681
|
+
ctx.ui.setStatus(STATUS_KEY, composeFooterStatus(state.activeRole.name, intent));
|
|
682
|
+
}
|
|
683
|
+
const persisted = {
|
|
684
|
+
name: state.activeRole.name,
|
|
685
|
+
source: state.activeRole.source,
|
|
686
|
+
path: state.activeRole.path,
|
|
687
|
+
intent,
|
|
688
|
+
appliedAt: Date.now()
|
|
689
|
+
};
|
|
690
|
+
pi.appendEntry(ACTIVE_ROLE_ENTRY_TYPE, persisted);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
const e = err;
|
|
693
|
+
debugLog("title", "generateAndApplyTitle failed", {
|
|
694
|
+
name: e?.name,
|
|
695
|
+
message: e?.message ?? String(err),
|
|
696
|
+
stack: e?.stack,
|
|
697
|
+
status: e?.status ?? e?.statusCode,
|
|
698
|
+
code: e?.code,
|
|
699
|
+
cause: e?.cause ? String(e.cause) : void 0,
|
|
700
|
+
modelId: model?.id,
|
|
701
|
+
modelProvider: model?.provider ?? model?.api?.provider
|
|
702
|
+
});
|
|
703
|
+
if (!state.titleErrorShown && ctx.hasUI) {
|
|
704
|
+
ctx.ui.notify(
|
|
705
|
+
`pi-roles: title generation failed (${e?.message ?? String(err)}). Set settings.titleModel to a model with credentials.`,
|
|
706
|
+
"warning"
|
|
707
|
+
);
|
|
708
|
+
state.titleErrorShown = true;
|
|
709
|
+
}
|
|
710
|
+
} finally {
|
|
711
|
+
state.titleInFlight = false;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/index.ts
|
|
716
|
+
var FLAG_NAME = "role";
|
|
717
|
+
var ENV_VAR = "PI_ROLE";
|
|
718
|
+
var SUBCOMMANDS = ["list", "current", "reload"];
|
|
719
|
+
function index_default(pi) {
|
|
720
|
+
const state = {
|
|
721
|
+
activeRole: null,
|
|
722
|
+
pendingRoleAfterReset: null,
|
|
723
|
+
roles: [],
|
|
724
|
+
shadowed: [],
|
|
725
|
+
settings: {},
|
|
726
|
+
intent: void 0,
|
|
727
|
+
titleInFlight: false,
|
|
728
|
+
titleErrorShown: false
|
|
729
|
+
};
|
|
730
|
+
const refreshFromDisk = (cwd) => {
|
|
731
|
+
state.settings = loadSettings(cwd);
|
|
732
|
+
const discovery = discoverRoles(cwd, state.settings.roleScope ?? "both");
|
|
733
|
+
state.roles = discovery.roles;
|
|
734
|
+
state.shadowed = discovery.shadowed;
|
|
735
|
+
};
|
|
736
|
+
pi.registerFlag(FLAG_NAME, {
|
|
737
|
+
type: "string",
|
|
738
|
+
description: "Launch as the named pi-roles role (e.g. --role architect)."
|
|
739
|
+
});
|
|
740
|
+
pi.registerMessageRenderer(ROLE_NOTIFICATION_MESSAGE_TYPE, () => {
|
|
741
|
+
return void 0;
|
|
742
|
+
});
|
|
743
|
+
pi.on("session_start", async (event, ctx) => {
|
|
744
|
+
refreshFromDisk(ctx.cwd);
|
|
745
|
+
const restored = findRestoredState(ctx);
|
|
746
|
+
debugLog("index", `session_start reason=${event.reason}`, restored ? { name: restored.name, intent: restored.intent } : void 0);
|
|
747
|
+
let targetName;
|
|
748
|
+
let preservedIntent;
|
|
749
|
+
let silent = false;
|
|
750
|
+
if (state.pendingRoleAfterReset) {
|
|
751
|
+
targetName = state.pendingRoleAfterReset;
|
|
752
|
+
state.pendingRoleAfterReset = null;
|
|
753
|
+
} else if ((event.reason === "reload" || event.reason === "resume") && restored) {
|
|
754
|
+
targetName = restored.name;
|
|
755
|
+
preservedIntent = restored.intent;
|
|
756
|
+
silent = true;
|
|
757
|
+
} else {
|
|
758
|
+
targetName = event.reason === "new" ? pickNewSessionRoleName(state.activeRole, pi, state.settings, state.roles) : pickInitialRoleName(pi, state.settings, state.roles);
|
|
759
|
+
silent = event.reason === "startup";
|
|
760
|
+
}
|
|
761
|
+
state.intent = preservedIntent;
|
|
762
|
+
await applyResolved(pi, ctx, state, targetName, { silent, preservedIntent });
|
|
763
|
+
});
|
|
764
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
765
|
+
debugLog("index", "before_agent_start fired", {
|
|
766
|
+
hasActiveRole: !!state.activeRole,
|
|
767
|
+
hasIntent: !!state.intent,
|
|
768
|
+
inFlight: state.titleInFlight,
|
|
769
|
+
promptLen: event?.prompt?.length ?? 0,
|
|
770
|
+
ctxModelId: ctx?.model?.id
|
|
771
|
+
});
|
|
772
|
+
if (state.activeRole && !state.intent && !state.titleInFlight && event.prompt && event.prompt.trim().length > 0) {
|
|
773
|
+
debugLog("index", "triggering title generation", { promptPreview: event.prompt.slice(0, 80), model: state.settings.titleModel });
|
|
774
|
+
void generateAndApplyTitle({
|
|
775
|
+
prompt: event.prompt,
|
|
776
|
+
state,
|
|
777
|
+
pi,
|
|
778
|
+
ctx,
|
|
779
|
+
configuredTitleModel: state.settings.titleModel
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return composeSystemPrompt(state, pi);
|
|
783
|
+
});
|
|
784
|
+
pi.registerCommand("role", {
|
|
785
|
+
description: "Switch session role. /role list | current | reload | <name> [--reset]",
|
|
786
|
+
getArgumentCompletions: (prefix) => roleCompletions(prefix, state.roles),
|
|
787
|
+
handler: async (args, ctx) => {
|
|
788
|
+
refreshFromDisk(ctx.cwd);
|
|
789
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
790
|
+
const sub = tokens[0];
|
|
791
|
+
if (!sub || sub === "list") {
|
|
792
|
+
return handleList(ctx, state);
|
|
793
|
+
}
|
|
794
|
+
if (sub === "current") {
|
|
795
|
+
return handleCurrent(ctx, state);
|
|
796
|
+
}
|
|
797
|
+
if (sub === "reload") {
|
|
798
|
+
return handleReload(pi, ctx, state);
|
|
799
|
+
}
|
|
800
|
+
const wantsReset = tokens.includes("--reset");
|
|
801
|
+
const name = sub;
|
|
802
|
+
if (wantsReset) {
|
|
803
|
+
state.pendingRoleAfterReset = name;
|
|
804
|
+
const result = await resetSession(ctx);
|
|
805
|
+
if (result.cancelled) {
|
|
806
|
+
state.pendingRoleAfterReset = null;
|
|
807
|
+
ctx.ui.notify(`Role switch to "${name}" cancelled.`, "info");
|
|
808
|
+
}
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
await applyResolved(pi, ctx, state, name, { silent: false, preservedIntent: state.intent });
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
function composeSystemPrompt(state, pi) {
|
|
816
|
+
if (!state.activeRole) return void 0;
|
|
817
|
+
const body = state.activeRole.body;
|
|
818
|
+
const mode = effectiveIntercomMode(state.activeRole, state.settings.intercomMode);
|
|
819
|
+
const addendum = mode !== "off" && isIntercomAvailable(pi) ? intercomPromptAddendum(mode, pi.getSessionName()) : "";
|
|
820
|
+
const parts = [body, addendum].filter((p) => p.length > 0);
|
|
821
|
+
if (parts.length === 0) return void 0;
|
|
822
|
+
return { systemPrompt: parts.join("\n\n") };
|
|
823
|
+
}
|
|
824
|
+
function pickNewSessionRoleName(activeRole, pi, settings, roles) {
|
|
825
|
+
if (settings.preserveRoleOnNewSession && activeRole) return activeRole.name;
|
|
826
|
+
return pickInitialRoleName(pi, settings, roles);
|
|
827
|
+
}
|
|
828
|
+
function pickInitialRoleName(pi, settings, roles) {
|
|
829
|
+
const flagValue = pi.getFlag(FLAG_NAME);
|
|
830
|
+
if (typeof flagValue === "string" && flagValue.length > 0) return flagValue;
|
|
831
|
+
const env = process.env[ENV_VAR];
|
|
832
|
+
if (env && env.length > 0) return env;
|
|
833
|
+
const configured = settings.defaultRole;
|
|
834
|
+
if (configured && roles.some((r) => r.frontmatter.name === configured)) {
|
|
835
|
+
return configured;
|
|
836
|
+
}
|
|
837
|
+
return BUILTIN_ROLE_ASSISTANT_NAME;
|
|
838
|
+
}
|
|
839
|
+
function findRestoredState(ctx) {
|
|
840
|
+
let entries;
|
|
841
|
+
try {
|
|
842
|
+
entries = ctx.sessionManager.getEntries();
|
|
843
|
+
} catch {
|
|
844
|
+
return void 0;
|
|
845
|
+
}
|
|
846
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
847
|
+
const e = entries[i];
|
|
848
|
+
if (e && e.type === "custom" && e.customType === ACTIVE_ROLE_ENTRY_TYPE) {
|
|
849
|
+
return e.data ?? void 0;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return void 0;
|
|
853
|
+
}
|
|
854
|
+
async function applyResolved(pi, ctx, state, name, options) {
|
|
855
|
+
let resolved;
|
|
856
|
+
try {
|
|
857
|
+
resolved = resolveRole(name, state.roles);
|
|
858
|
+
} catch (err) {
|
|
859
|
+
const message = err instanceof RoleResolutionError ? err.message : String(err);
|
|
860
|
+
debugLog("index", `applyResolved fallback: ${message}`);
|
|
861
|
+
if (ctx.hasUI) {
|
|
862
|
+
ctx.ui.notify(`pi-roles: ${message} Falling back to ${BUILTIN_ROLE_ASSISTANT_NAME}.`, "warning");
|
|
863
|
+
}
|
|
864
|
+
const fallback = findBuiltInAssistant(state.roles);
|
|
865
|
+
if (!fallback) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
resolved = resolveRole(BUILTIN_ROLE_ASSISTANT_NAME, state.roles);
|
|
869
|
+
}
|
|
870
|
+
const result = await applyRole(
|
|
871
|
+
resolved,
|
|
872
|
+
{
|
|
873
|
+
pi,
|
|
874
|
+
ctx,
|
|
875
|
+
warnOnMissingMcp: state.settings.warnOnMissingMcp ?? true,
|
|
876
|
+
intercomMode: state.settings.intercomMode
|
|
877
|
+
},
|
|
878
|
+
options
|
|
879
|
+
);
|
|
880
|
+
state.activeRole = resolved;
|
|
881
|
+
state.intent = result.state.intent;
|
|
882
|
+
debugLog("index", `applied role=${resolved.name}`, { intent: result.state.intent, warnings: result.warnings });
|
|
883
|
+
if (ctx.hasUI && result.warnings.length > 0 && !options.silent) {
|
|
884
|
+
for (const w of result.warnings) ctx.ui.notify(`pi-roles: ${w}`, "warning");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async function handleList(ctx, state) {
|
|
888
|
+
if (state.roles.length === 0) {
|
|
889
|
+
ctx.ui.notify(
|
|
890
|
+
"pi-roles: no roles found. Create one in .pi/roles/ or ~/.pi/agent/roles/.",
|
|
891
|
+
"info"
|
|
892
|
+
);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const lines = state.roles.slice().sort((a, b) => a.frontmatter.name.localeCompare(b.frontmatter.name)).map((r) => {
|
|
896
|
+
const marker = state.activeRole?.name === r.frontmatter.name ? "* " : " ";
|
|
897
|
+
return `${marker}${r.frontmatter.name} [${r.source}] \u2014 ${r.frontmatter.description}`;
|
|
898
|
+
});
|
|
899
|
+
const shadowed = state.shadowed.map(
|
|
900
|
+
(s) => ` ${s.name} [${s.source}] (shadowed) \u2014 ${s.path}`
|
|
901
|
+
);
|
|
902
|
+
const all = shadowed.length > 0 ? ["Available roles:", ...lines, "", "Shadowed (lower-priority duplicates):", ...shadowed] : ["Available roles:", ...lines];
|
|
903
|
+
ctx.ui.notify(all.join("\n"), "info");
|
|
904
|
+
}
|
|
905
|
+
async function handleCurrent(ctx, state) {
|
|
906
|
+
if (!state.activeRole) {
|
|
907
|
+
ctx.ui.notify("pi-roles: no role active.", "info");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const r = state.activeRole;
|
|
911
|
+
const chain = r.extendsChain.length > 1 ? ` (extends: ${r.extendsChain.slice(1).join(" \u2192 ")})` : "";
|
|
912
|
+
ctx.ui.notify(`pi-roles: ${r.name}${chain} \u2014 ${r.description}
|
|
913
|
+
${r.path}`, "info");
|
|
914
|
+
}
|
|
915
|
+
async function handleReload(pi, ctx, state) {
|
|
916
|
+
const previous = state.activeRole?.name ?? pickInitialRoleName(pi, state.settings, state.roles);
|
|
917
|
+
await applyResolved(pi, ctx, state, previous, {
|
|
918
|
+
silent: false,
|
|
919
|
+
preservedIntent: state.intent
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
function roleCompletions(prefix, roles) {
|
|
923
|
+
const needle = prefix.toLowerCase();
|
|
924
|
+
const items = [];
|
|
925
|
+
for (const sub of SUBCOMMANDS) {
|
|
926
|
+
if (sub.toLowerCase().startsWith(needle)) {
|
|
927
|
+
items.push({ value: sub, label: sub, description: subcommandDescription(sub) });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
for (const r of roles) {
|
|
931
|
+
if (r.frontmatter.name.toLowerCase().startsWith(needle)) {
|
|
932
|
+
items.push({
|
|
933
|
+
value: r.frontmatter.name,
|
|
934
|
+
label: r.frontmatter.name,
|
|
935
|
+
description: `${r.source} \u2014 ${r.frontmatter.description}`
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return items.length > 0 ? items : null;
|
|
940
|
+
}
|
|
941
|
+
function subcommandDescription(sub) {
|
|
942
|
+
switch (sub) {
|
|
943
|
+
case "list":
|
|
944
|
+
return "Show all available roles.";
|
|
945
|
+
case "current":
|
|
946
|
+
return "Show the active role.";
|
|
947
|
+
case "reload":
|
|
948
|
+
return "Re-read the active role file from disk.";
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
export {
|
|
952
|
+
composeSystemPrompt,
|
|
953
|
+
index_default as default,
|
|
954
|
+
pickInitialRoleName,
|
|
955
|
+
pickNewSessionRoleName,
|
|
956
|
+
roleCompletions
|
|
957
|
+
};
|