pi-feats 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +90 -9
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-log-store.ts +4 -2
  4. package/extensions/api-server/application-runtime.ts +79 -8
  5. package/extensions/api-server/application-store.ts +23 -10
  6. package/extensions/api-server/index.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +109 -39
  8. package/extensions/api-server/server.ts +220 -44
  9. package/extensions/cli-resources.ts +144 -113
  10. package/extensions/cli-tui-mode.ts +7 -0
  11. package/extensions/context-memory/index.ts +6 -1
  12. package/extensions/conversation-search/index.ts +42 -0
  13. package/extensions/guardrails/index.ts +2 -1
  14. package/extensions/lib/application-context.ts +2 -0
  15. package/extensions/lib/builtin-tools.ts +24 -0
  16. package/extensions/lib/cli-tui-mode.ts +72 -0
  17. package/extensions/lib/context-memory.ts +6 -1
  18. package/extensions/lib/conversation-search.ts +33 -0
  19. package/extensions/lib/package-resources.ts +58 -0
  20. package/extensions/lib/pi-command.ts +11 -0
  21. package/extensions/lib/profile-sandbox.ts +14 -3
  22. package/extensions/lib/remote-hosts.ts +7 -5
  23. package/extensions/lib/workflow-evaluator.ts +113 -0
  24. package/extensions/lib/workflow-harness.ts +409 -43
  25. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  26. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  27. package/extensions/pi-console-webui/app/globals.css +3 -0
  28. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  29. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  30. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  31. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  32. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  33. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  34. package/extensions/pi-console-webui/components/console-page.tsx +4 -0
  35. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  36. package/extensions/pi-console-webui/components/console.tsx +32 -11
  37. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  38. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  39. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  40. package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
  41. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  42. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  43. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  44. package/extensions/pi-console-webui/index.ts +2 -1
  45. package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
  46. package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
  47. package/extensions/pi-console-webui/next.config.js +1 -0
  48. package/extensions/profile-ssh.ts +274 -0
  49. package/extensions/profiles.ts +69 -25
  50. package/extensions/pulse/index.ts +148 -16
  51. package/extensions/pulse/store.ts +132 -18
  52. package/extensions/sequential-workflow.ts +121 -79
  53. package/extensions/skill-sources/store.ts +29 -10
  54. package/package.json +21 -9
  55. package/scripts/build-console-webui.mjs +7 -0
  56. package/scripts/console-webui-cache.mjs +79 -0
  57. package/scripts/ensure-console-build.mjs +43 -0
  58. package/skills/profile-ssh/SKILL.md +65 -0
  59. package/skills/sequential-workflow/SKILL.md +4 -3
  60. package/themes/legacy-dark.json +85 -0
@@ -1,20 +1,34 @@
1
1
  import { existsSync } from "node:fs";
2
- import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { copyFile, cp, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
3
3
  import { join, relative, resolve } from "node:path";
4
4
  import { ensureProfileSandbox } from "../lib/profile-sandbox.ts";
5
+ import { BUILTIN_TOOLS, activeBuiltinTools, updatedBuiltinTools } from "../lib/builtin-tools.ts";
6
+ import { packageExtensions } from "../lib/package-resources.ts";
5
7
 
6
- export const BUILTIN_TOOLS = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"];
8
+ export { BUILTIN_TOOLS } from "../lib/builtin-tools.ts";
7
9
  export const RESOURCE_KINDS = ["tools", "skills", "extensions"] as const;
8
10
  export type ResourceKind = typeof RESOURCE_KINDS[number];
9
11
  type PolicyKey = "enabledTools" | "enabledSkills" | "enabledProfileSkills" | "enabledExtensions";
10
12
  type SkillSources = { shared?: boolean; profile?: boolean };
11
13
  type SkillResourceSource = "shared" | "profile" | "package";
12
- type ProfilePolicy = Partial<Record<PolicyKey, string[]>> & { skillSources?: SkillSources; contextMemory?: { mode: "file"; target: "profile" | "identity" } | { mode: "handler"; handler: string } };
14
+ export type ProfileMetadata = { description: string; tags: string[] };
15
+ type ProfilePolicy = Partial<Record<PolicyKey, string[]>> & Partial<ProfileMetadata> & { skillSources?: SkillSources; contextMemory?: { mode: "file"; target: "profile" | "identity" } | { mode: "handler"; handler: string } };
13
16
  export type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy; sandbox?: boolean };
14
17
  export type Resource = { name: string; kind: ResourceKind; path?: string; source: "builtin" | "shared" | "profile" | "extension" | "package"; package?: string; enabled: boolean; protected?: boolean };
15
18
  export type Package = { name: string; version?: string; description?: string; enabled: boolean; installed: boolean };
16
19
 
17
20
  const validName = (name: string) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name);
21
+ const maxProfileDescriptionLength = 500;
22
+ const maxProfileTags = 20;
23
+ const maxProfileTagLength = 50;
24
+ const profileMetadata = (value: unknown): ProfileMetadata => {
25
+ const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
26
+ if (input.description !== undefined && (typeof input.description !== "string" || input.description.length > maxProfileDescriptionLength)) throw Object.assign(new Error(`description must be a string of at most ${maxProfileDescriptionLength} characters.`), { status: 400 });
27
+ if (input.tags !== undefined && (!Array.isArray(input.tags) || input.tags.some((tag) => typeof tag !== "string"))) throw Object.assign(new Error("tags must be an array of strings."), { status: 400 });
28
+ const tags = [...new Map((input.tags as string[] | undefined ?? []).map((tag) => tag.trim()).filter(Boolean).map((tag) => [tag.toLocaleLowerCase(), tag])).values()];
29
+ if (tags.length > maxProfileTags || tags.some((tag) => tag.length > maxProfileTagLength)) throw Object.assign(new Error(`Use at most ${maxProfileTags} tags of at most ${maxProfileTagLength} characters.`), { status: 400 });
30
+ return { description: (input.description as string | undefined ?? "").trim(), tags };
31
+ };
18
32
  const policyKey: Record<ResourceKind, PolicyKey> = {
19
33
  tools: "enabledTools",
20
34
  skills: "enabledSkills",
@@ -24,12 +38,13 @@ const protectedExtensions = new Set(["profiles", "api-server"]);
24
38
 
25
39
  export class ProfileStore {
26
40
  private readonly extensionTools = new Map<string, Set<string>>();
41
+ readonly agentDir: string;
27
42
 
28
- constructor(readonly agentDir: string) {}
43
+ constructor(agentDir: string) { this.agentDir = agentDir; }
29
44
 
30
45
  registerExtensionTools(profile: string, names: Iterable<string>): void {
31
46
  const tools = this.extensionTools.get(profile) ?? new Set<string>();
32
- for (const name of names) if (name && !BUILTIN_TOOLS.includes(name)) tools.add(name);
47
+ for (const name of names) if (name && !(BUILTIN_TOOLS as readonly string[]).includes(name)) tools.add(name);
33
48
  this.extensionTools.set(profile, tools);
34
49
  }
35
50
 
@@ -49,6 +64,13 @@ export class ProfileStore {
49
64
  return join(this.profilesDir(), name);
50
65
  }
51
66
  private settingsPath(name: string): string { return join(this.directory(name), "settings.json"); }
67
+ private async linkSharedModels(name: string): Promise<void> {
68
+ if (name === "default") return;
69
+ const source = join(this.agentDir, "models.json"), target = join(this.directory(name), "models.json");
70
+ try { if (resolve(this.directory(name), await readlink(target)) === source) return; } catch { /* Missing or non-symlink target must be replaced. */ }
71
+ await rm(target, { force: true });
72
+ if (existsSync(source)) await symlink(source, target);
73
+ }
52
74
  private skillSources(name: string, settings: ProfileSettings): Required<SkillSources> {
53
75
  if (name === "default") return { shared: true, profile: false };
54
76
  return { shared: settings.profile?.skillSources?.shared !== false, profile: settings.profile?.skillSources?.profile === true };
@@ -62,6 +84,7 @@ export class ProfileStore {
62
84
  try {
63
85
  const value = JSON.parse(await readFile(path, "utf8")) as unknown;
64
86
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("settings.json must contain an object");
87
+ await this.linkSharedModels(name);
65
88
  return value as ProfileSettings;
66
89
  } catch (error) {
67
90
  if ((error as NodeJS.ErrnoException).code === "ENOENT" && name === "default") return {};
@@ -70,14 +93,13 @@ export class ProfileStore {
70
93
  }
71
94
  }
72
95
 
73
- async list(): Promise<Array<{ name: string; path: string }>> {
74
- const profiles = [{ name: "default", path: this.agentDir }];
75
- if (!existsSync(this.profilesDir())) return profiles;
76
- for (const entry of await readdir(this.profilesDir(), { withFileTypes: true })) {
77
- if (entry.isDirectory() && existsSync(join(this.profilesDir(), entry.name, "settings.json"))) {
78
- profiles.push({ name: entry.name, path: this.directory(entry.name) });
79
- }
80
- }
96
+ async list(): Promise<Array<{ name: string; path: string } & ProfileMetadata>> {
97
+ const names = ["default"];
98
+ if (existsSync(this.profilesDir())) for (const entry of await readdir(this.profilesDir(), { withFileTypes: true })) if (entry.isDirectory() && existsSync(join(this.profilesDir(), entry.name, "settings.json"))) names.push(entry.name);
99
+ const profiles = await Promise.all(names.map(async (name) => {
100
+ const settings = await this.readSettings(name);
101
+ return { name, path: this.directory(name), ...profileMetadata(settings.profile) };
102
+ }));
81
103
  return profiles.sort((a, b) => a.name.localeCompare(b.name));
82
104
  }
83
105
 
@@ -106,6 +128,7 @@ export class ProfileStore {
106
128
  private enabled(settings: ProfileSettings, kind: ResourceKind, name: string, path?: string): boolean {
107
129
  const values = settings.profile?.[policyKey[kind]];
108
130
  if (values) return values.includes("*") || values.includes(name);
131
+ if (kind === "tools" && (BUILTIN_TOOLS as readonly string[]).includes(name)) return activeBuiltinTools(settings).includes(name);
109
132
  const configured = settings[kind];
110
133
  const exclusions = Array.isArray(configured) ? configured.filter((value): value is string => typeof value === "string" && value.startsWith("!")) : [];
111
134
  return !path || !exclusions.includes(`!${path}`);
@@ -172,19 +195,15 @@ export class ProfileStore {
172
195
  }
173
196
 
174
197
  private async packageExtensions(settings: ProfileSettings): Promise<Array<{ name: string; path: string; package: string }>> {
175
- const entries: Array<{ name: string; path: string; package: string }> = [];
176
- for (const pkg of await this.configuredPackages(settings)) {
177
- for (const extension of Array.isArray(pkg.manifest?.pi?.extensions) ? pkg.manifest.pi.extensions : []) if (typeof extension === "string") entries.push({ name: extension.split("/").pop()?.replace(/\.(?:ts|js)$/, "") ?? pkg.name, path: join(pkg.base, extension), package: pkg.name });
178
- }
179
- return entries;
198
+ return packageExtensions(await this.configuredPackages(settings)).map(({ name, path, packageName }) => ({ name, path, package: packageName }));
180
199
  }
181
200
 
182
201
  private async packageToolSources(settings: ProfileSettings, names: Iterable<string>): Promise<Map<string, string>> {
183
202
  const sources = new Map<string, string>(), tools = [...names];
184
203
  for (const extension of await this.packageExtensions(settings)) try {
185
204
  const source = await readFile(extension.path, "utf8");
186
- for (const tool of tools) if (source.includes(`"${tool}"`) || source.includes(`'${tool}'`) || source.includes(`\`${tool}\``)) sources.set(tool, extension.package);
187
- for (const block of source.matchAll(/TOOL_NAMES[^=]*=\s*\{([\s\S]*?)\}/g)) for (const name of block[1].matchAll(/:\s*["']([A-Za-z][A-Za-z0-9_-]{0,63})["']/g)) sources.set(name[1], extension.package);
205
+ for (const tool of tools) if (!(BUILTIN_TOOLS as readonly string[]).includes(tool) && (source.includes(`"${tool}"`) || source.includes(`'${tool}'`) || source.includes(`\`${tool}\``))) sources.set(tool, extension.package);
206
+ for (const block of source.matchAll(/TOOL_NAMES[^=]*=\s*\{([\s\S]*?)\}/g)) for (const name of block[1].matchAll(/:\s*["']([A-Za-z][A-Za-z0-9_-]{0,63})["']/g)) if (!(BUILTIN_TOOLS as readonly string[]).includes(name[1])) sources.set(name[1], extension.package);
188
207
  } catch {}
189
208
  return sources;
190
209
  }
@@ -276,6 +295,19 @@ export class ProfileStore {
276
295
  async setResource(profile: string, kind: ResourceKind, name: string, enabled: boolean, source?: SkillResourceSource): Promise<Resource> {
277
296
  const current = await this.resource(profile, kind, name, source);
278
297
  if (current.protected && !enabled) throw Object.assign(new Error("This extension is required and cannot be disabled."), { status: 422 });
298
+ // The default profile uses Pi's defaultTools setting for native tool state
299
+ // unless it already has a legacy explicit enabledTools policy. Named profiles
300
+ // retain their explicit enabledTools policy.
301
+ if (kind === "tools" && current.source === "builtin" && profile === "default") {
302
+ const settings = await this.readSettings(profile);
303
+ if (!settings.profile?.enabledTools) {
304
+ const tools = updatedBuiltinTools(settings, name, enabled);
305
+ if (tools) settings.defaultTools = tools;
306
+ else delete settings.defaultTools;
307
+ await this.writeSettings(profile, settings);
308
+ return this.resource(profile, kind, name, source);
309
+ }
310
+ }
279
311
  // Extensions are shared runtime resources, so toggling one always updates
280
312
  // the default runtime rather than materializing it in a profile workspace.
281
313
  const settingsProfile = kind === "extensions" ? "default" : profile;
@@ -299,29 +331,67 @@ export class ProfileStore {
299
331
  await this.refreshSkills(profile);
300
332
  }
301
333
 
302
- async create(name: string): Promise<{ name: string; path: string }> {
334
+ async create(name: string, metadata: unknown = {}, cloneFrom?: string): Promise<{ name: string; path: string } & ProfileMetadata> {
303
335
  if (!validName(name) || name === "default") throw Object.assign(new Error("Invalid profile name"), { status: 400 });
304
336
  const destination = this.directory(name);
305
337
  if (existsSync(destination)) throw Object.assign(new Error("Profile already exists"), { status: 409 });
306
- await mkdir(join(destination, "sessions"), { recursive: true });
307
- let base: ProfileSettings = {};
308
- try { base = await this.readSettings("default"); } catch {}
309
- const { packages: _packages, extensions: _extensions, ...profileBase } = base;
310
- const settings: ProfileSettings = {
311
- ...profileBase,
312
- defaultTools: BUILTIN_TOOLS,
313
- sandbox: true,
314
- profile: { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false } },
315
- };
316
- await this.writeSettings(name, settings);
317
- await ensureProfileSandbox(destination, resolve(process.argv[1]));
318
- await writeFile(join(destination, "SOUL.md"), "", "utf8");
319
- await writeFile(join(destination, "guardrails.json"), "{\n \"guardrails\": []\n}\n", "utf8");
320
- for (const file of ["auth.json", "models.json"]) {
321
- const source = join(this.agentDir, file);
322
- if (existsSync(source)) await copyFile(source, join(destination, file));
338
+ const details = profileMetadata(metadata);
339
+ if (cloneFrom !== undefined && (typeof cloneFrom !== "string" || !validName(cloneFrom) || cloneFrom === "default")) throw Object.assign(new Error("Clone source must be an existing named profile."), { status: 400 });
340
+
341
+ let sourceSettings: ProfileSettings | undefined;
342
+ let sourceDirectory: string | undefined;
343
+ if (cloneFrom) {
344
+ sourceSettings = await this.readSettings(cloneFrom);
345
+ sourceDirectory = this.directory(cloneFrom);
323
346
  }
324
- return { name, path: destination };
347
+
348
+ try {
349
+ await mkdir(join(destination, "sessions"), { recursive: true });
350
+ let settings: ProfileSettings;
351
+ if (sourceSettings && sourceDirectory) {
352
+ const sourceSkills = join(sourceDirectory, "skills");
353
+ if (existsSync(sourceSkills)) await cp(sourceSkills, join(destination, "skills"), { recursive: true, force: false });
354
+ for (const file of ["SOUL.md", "REFINE.md", "guardrails.json", ".env", "auth.json"]) {
355
+ const source = join(sourceDirectory, file);
356
+ if (existsSync(source)) await copyFile(source, join(destination, file));
357
+ }
358
+ settings = {
359
+ ...sourceSettings,
360
+ sandbox: true,
361
+ profile: { ...(sourceSettings.profile ?? {}), ...details },
362
+ };
363
+ } else {
364
+ let base: ProfileSettings = {};
365
+ try { base = await this.readSettings("default"); } catch {}
366
+ const { packages: _packages, extensions: _extensions, ...profileBase } = base;
367
+ settings = {
368
+ ...profileBase,
369
+ defaultTools: BUILTIN_TOOLS,
370
+ sandbox: true,
371
+ profile: { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false }, ...details },
372
+ };
373
+ }
374
+ await this.writeSettings(name, settings);
375
+ await ensureProfileSandbox(destination, resolve(process.argv[1]));
376
+ if (!sourceSettings) {
377
+ await writeFile(join(destination, "SOUL.md"), "", "utf8");
378
+ await writeFile(join(destination, "guardrails.json"), "{\n \"guardrails\": []\n}\n", "utf8");
379
+ const auth = join(this.agentDir, "auth.json");
380
+ if (existsSync(auth)) await copyFile(auth, join(destination, "auth.json"));
381
+ }
382
+ await this.linkSharedModels(name);
383
+ return { name, path: destination, ...details };
384
+ } catch (error) {
385
+ await rm(destination, { recursive: true, force: true });
386
+ throw error;
387
+ }
388
+ }
389
+
390
+ async updateMetadata(name: string, metadata: unknown): Promise<{ name: string; path: string } & ProfileMetadata> {
391
+ const details = profileMetadata(metadata);
392
+ const settings = await this.readSettings(name);
393
+ await this.writeSettings(name, { ...settings, profile: { ...(settings.profile ?? {}), ...details } });
394
+ return { name, path: this.directory(name), ...details };
325
395
  }
326
396
 
327
397
  async delete(name: string): Promise<void> {