pi-profile-switch 0.3.1 → 0.4.2

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 (54) hide show
  1. package/README.md +31 -36
  2. package/README.zh-CN.md +31 -36
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -13
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +45 -93
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -41
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +14 -26
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -778
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/default-profiles.ts +0 -59
  44. package/src/mcp-overlay-file.ts +0 -35
  45. package/src/mcp-overlay.ts +0 -122
  46. package/src/model-selection.ts +0 -64
  47. package/src/name-matching.ts +0 -50
  48. package/src/profile-badge.ts +0 -142
  49. package/src/profile-presets.ts +0 -61
  50. package/src/skill-selection.ts +0 -81
  51. package/src/startup-mcp-scope.ts +0 -271
  52. package/src/startup-selection.ts +0 -201
  53. package/src/switching/activate-profile.ts +0 -144
  54. package/src/switching/apply-profile.ts +0 -131
@@ -1,75 +0,0 @@
1
- /**
2
- * AdapterPresence: a cheap, deterministic "is pi-mcp-adapter installed?"
3
- * check that does not depend on extension load order.
4
- *
5
- * The overlay mechanism only exists to serve the adapter; when the adapter is
6
- * absent the extension must not register the `mcp-config` flag default and
7
- * must not write anything. Signals, in order of reliability:
8
- *
9
- * 1. Pi's npm package root (`<agentDir>/npm/node_modules/pi-mcp-adapter`).
10
- * 2. The command line (`-e <path>` / `--extension <path>`).
11
- * 3. Pi settings `packages` entries.
12
- * 4. The adapter's own event-bus presence probe, when it answered during
13
- * extension loading (only reliable when the adapter loaded first).
14
- *
15
- * Any failure reads as "absent": a false negative only disables the overlay,
16
- * while a false positive could hide the user's own Pi-global slot file.
17
- */
18
-
19
- import { existsSync, readFileSync } from "node:fs";
20
- import path from "node:path";
21
-
22
- import { isRecord } from "./json-file.ts";
23
-
24
- const ADAPTER_PACKAGE = "pi-mcp-adapter";
25
-
26
- export interface AdapterPresenceInput {
27
- agentDir: string;
28
- argv: readonly string[];
29
- /** Result of the adapter's event-bus probe, when the caller ran one. */
30
- probeAnswered?: boolean;
31
- }
32
-
33
- export function adapterPresent(input: AdapterPresenceInput): boolean {
34
- if (input.probeAnswered === true) return true;
35
- try {
36
- for (const candidate of [
37
- path.join(input.agentDir, "npm", "node_modules", ADAPTER_PACKAGE),
38
- path.join(input.agentDir, "node_modules", ADAPTER_PACKAGE),
39
- ]) {
40
- if (existsSync(candidate)) return true;
41
- }
42
- return argvMentionsAdapter(input.argv) || settingsListAdapter(input.agentDir);
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
- function argvMentionsAdapter(argv: readonly string[]): boolean {
49
- for (let index = 0; index < argv.length; index++) {
50
- const token = argv[index] ?? "";
51
- if (token.includes(ADAPTER_PACKAGE)) return true;
52
- if ((token === "-e" || token === "--extension") && (argv[index + 1] ?? "").includes(ADAPTER_PACKAGE)) {
53
- return true;
54
- }
55
- }
56
- return false;
57
- }
58
-
59
- function settingsListAdapter(agentDir: string): boolean {
60
- try {
61
- const raw: unknown = JSON.parse(readFileSync(path.join(agentDir, "settings.json"), "utf8"));
62
- if (!isRecord(raw) || !Array.isArray(raw.packages)) return false;
63
- return raw.packages.some((entry) => {
64
- const source =
65
- typeof entry === "string"
66
- ? entry
67
- : isRecord(entry) && typeof entry.source === "string"
68
- ? entry.source
69
- : undefined;
70
- return source !== undefined && source.includes(ADAPTER_PACKAGE);
71
- });
72
- } catch {
73
- return false;
74
- }
75
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * DefaultProfiles: the catalog a fresh install starts from.
3
- *
4
- * Pi packages have no install hook (docs/packages.md) — `pi install` only
5
- * unpacks the package and runs `npm install` — so "installing the default
6
- * profiles" can only happen the first time the extension actually loads.
7
- * `seedDefaultProfilesSync` therefore runs at load time, before any session
8
- * event, and is idempotent: an existing catalog file is never read, rewritten,
9
- * or backed up, and the package never seeds again once the file exists.
10
- *
11
- * The seeded content is the shipped preset catalog, exactly what
12
- * `examples/profiles.json` publishes, reached through the same
13
- * `PROFILE_PRESETS` data `/profile create` offers — one definition, so the
14
- * three cannot drift. The `read-only` profile declares built-in tools and
15
- * instructions only: it activates on a machine with no skills, no
16
- * `pi-mcp-adapter`, and no credentials, and it changes nothing until the
17
- * user selects it.
18
- *
19
- * `profile-presets.test.ts` pins the content contract: this catalog equals
20
- * the published `examples/profiles.json` and every definition is
21
- * resource-free.
22
- */
23
-
24
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
25
- import path from "node:path";
26
-
27
- import { DEFAULT_PROFILE_NAME, PROFILE_SCHEMA_VERSION } from "./profile-catalog.ts";
28
- import { PROFILE_PRESETS } from "./profile-presets.ts";
29
-
30
- /** The catalog written to `<agentDir>/profiles.json` on first load. */
31
- export const DEFAULT_PROFILE_CATALOG = {
32
- schemaVersion: PROFILE_SCHEMA_VERSION,
33
- profiles: Object.fromEntries(
34
- PROFILE_PRESETS.filter((preset) => preset.name !== DEFAULT_PROFILE_NAME).map((preset) => [
35
- preset.name,
36
- preset.definition,
37
- ]),
38
- ),
39
- };
40
-
41
- export type DefaultSeedResult =
42
- /** The agent dir had no catalog and now holds the default one. */
43
- | "seeded"
44
- /** A catalog file was already there; it was not touched. */
45
- | "present";
46
-
47
- /**
48
- * Writes `<agentDir>/profiles.json` when it does not exist yet. Synchronous
49
- * on purpose: the load-time call site cannot await, and the write is one small
50
- * file. Existing files win unconditionally — an empty or hand-written catalog
51
- * is a user decision, not a missing default.
52
- */
53
- export function seedDefaultProfilesSync(agentDir: string, catalog: unknown = DEFAULT_PROFILE_CATALOG): DefaultSeedResult {
54
- const target = path.join(agentDir, "profiles.json");
55
- if (existsSync(target)) return "present";
56
- mkdirSync(agentDir, { recursive: true });
57
- writeFileSync(target, `${JSON.stringify(catalog, null, 2)}\n`);
58
- return "seeded";
59
- }
@@ -1,35 +0,0 @@
1
- /**
2
- * McpOverlayFile: the only writer of the generated MCP overlay.
3
- *
4
- * Invariants:
5
- * - Atomic (temporary file + rename) so a reader never sees a half-written
6
- * document.
7
- * - Written only when the bytes change: a profile switch that does not move
8
- * the MCP selection produces no write and therefore no reload.
9
- * - Mode 0600: the overlay carries the Pi-global slot's definitions verbatim
10
- * when the user keeps servers there, and those definitions may embed
11
- * credentials even though the overlay itself never introduces any.
12
- */
13
-
14
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
- import path from "node:path";
16
-
17
- /** Current bytes, or undefined when the file is missing/unreadable. */
18
- export function readMcpOverlaySync(overlayPath: string): string | undefined {
19
- try {
20
- return readFileSync(overlayPath, "utf8");
21
- } catch {
22
- return undefined;
23
- }
24
- }
25
-
26
- /** Writes `content` when it differs from what is on disk. Returns true when
27
- * the file changed. */
28
- export function writeMcpOverlayIfChangedSync(overlayPath: string, content: string): boolean {
29
- if (readMcpOverlaySync(overlayPath) === content) return false;
30
- mkdirSync(path.dirname(overlayPath), { recursive: true });
31
- const temporary = `${overlayPath}.tmp-${process.pid}`;
32
- writeFileSync(temporary, content, { mode: 0o600 });
33
- renameSync(temporary, overlayPath);
34
- return true;
35
- }
@@ -1,122 +0,0 @@
1
- /**
2
- * McpOverlay: the PURE half of the profile-scoped MCP filter.
3
- *
4
- * `pi-mcp-adapter` has no runtime allowlist channel (ADR-0002 amendment), so
5
- * the profile's `mcp` declaration is enforced by a generated config file the
6
- * adapter reads as its "Pi global override" slot (the slot `--mcp-config`
7
- * replaces). The file is a DISABLE OVERLAY: entries carry no connection
8
- * parameters and no credentials — `{ "<server>": { "disabled": true } }` is
9
- * the adapter's own idiom for `/mcp disable`, and its config merge is
10
- * per-field, so a stub merges onto the definition owned by the user's own
11
- * file.
12
- *
13
- * One exception is structural: because the overlay file REPLACES the Pi
14
- * global slot, the servers the user keeps in that slot are carried over
15
- * verbatim from the sidecar (`mcp.user.json`); a hand-written slot file is
16
- * adopted into the sidecar before the first overwrite.
17
- *
18
- * `allowed` semantics:
19
- * - `"all"` — the profile declares no `mcp`: nothing is disabled.
20
- * - `[]` — every discovered server is disabled.
21
- * - `["github", …]` — everything outside the list is disabled.
22
- */
23
-
24
- import path from "node:path";
25
-
26
- import { isRecord } from "./json-file.ts";
27
- import { matchesReference } from "./name-matching.ts";
28
-
29
- /**
30
- * The generated overlay REPLACES the adapter's Pi-global slot, so it lives at
31
- * the slot's default path. The user's own Pi-global servers move to a sidecar
32
- * that this package never writes except when adopting a hand-written slot.
33
- */
34
- export const MCP_SLOT_FILE_NAME = "mcp.json";
35
- export const MCP_SOURCE_FILE_NAME = "mcp.user.json";
36
- /** Top-level key marking a file as generated. The adapter ignores unknown
37
- * top-level keys, so the marker never reaches it as configuration. */
38
- export const MCP_GENERATED_MARKER = "piProfileSwitch";
39
-
40
- /** The adapter's Pi-global slot: the generated overlay. */
41
- export function mcpSlotPath(agentDir: string): string {
42
- return path.join(agentDir, MCP_SLOT_FILE_NAME);
43
- }
44
-
45
- /** The user-owned sidecar holding the Pi-global servers verbatim. */
46
- export function mcpSourcePath(agentDir: string): string {
47
- return path.join(agentDir, MCP_SOURCE_FILE_NAME);
48
- }
49
-
50
- /** True when the document was produced by pi-profile-switch. */
51
- export function isGeneratedOverlay(value: unknown): boolean {
52
- if (!isRecord(value)) return false;
53
- const marker = value[MCP_GENERATED_MARKER];
54
- return isRecord(marker) && marker.generated === true;
55
- }
56
-
57
- /** True for the credential-free stubs the overlay adds for servers defined in
58
- * the adapter's other sources. */
59
- export function isDisabledStub(value: unknown): boolean {
60
- return isRecord(value) && Object.keys(value).length === 1 && value.disabled === true;
61
- }
62
-
63
- /** Server names a profile's `mcp` references resolve to. `undefined` (the
64
- * profile declares nothing) means "no filtering" and is reported as the
65
- * literal `"all"`. Glob references follow the same rules as every other
66
- * profile reference. */
67
- export function resolveAllowedServers(
68
- refs: readonly string[] | undefined,
69
- discovered: readonly string[],
70
- ): readonly string[] | "all" {
71
- if (refs === undefined) return "all";
72
- const allowed = new Set<string>();
73
- for (const ref of refs) {
74
- for (const name of discovered) {
75
- if (matchesReference(ref, name)) allowed.add(name);
76
- }
77
- }
78
- return [...allowed];
79
- }
80
-
81
- export interface McpOverlayInput {
82
- /** Parsed Pi-global slot document, when the user has one. */
83
- slotDocument?: Record<string, unknown>;
84
- /** Server names defined in the adapter's other file sources. */
85
- otherServerNames: readonly string[];
86
- allowed: readonly string[] | "all";
87
- }
88
-
89
- /** Builds the overlay document. Server order is sorted so the serialized
90
- * bytes are stable and a rewrite only happens on a real change. */
91
- export function buildMcpOverlay(input: McpOverlayInput): Record<string, unknown> {
92
- const allowed = input.allowed === "all" ? undefined : new Set(input.allowed);
93
- const slotServers = isRecord(input.slotDocument?.mcpServers) ? input.slotDocument.mcpServers : {};
94
- const servers: Record<string, unknown> = {};
95
- for (const [name, definition] of Object.entries(slotServers)) {
96
- if (allowed === undefined || allowed.has(name) || !isRecord(definition)) {
97
- servers[name] = definition;
98
- continue;
99
- }
100
- servers[name] = { ...definition, disabled: true };
101
- }
102
- for (const name of input.otherServerNames) {
103
- if (name in servers) continue; // the slot's definition owns the name
104
- if (allowed === undefined || allowed.has(name)) continue;
105
- servers[name] = { disabled: true };
106
- }
107
- const document: Record<string, unknown> = {};
108
- for (const [key, value] of Object.entries(input.slotDocument ?? {})) {
109
- if (key !== "mcpServers" && key !== MCP_GENERATED_MARKER) document[key] = value;
110
- }
111
- document[MCP_GENERATED_MARKER] = { generated: true, version: 1 };
112
- document.mcpServers = Object.fromEntries(
113
- Object.entries(servers).sort(([left], [right]) => left.localeCompare(right)),
114
- );
115
- return document;
116
- }
117
-
118
- /** Stable serialization: two runs with the same semantics produce the same
119
- * bytes, so the writer can skip no-op writes. */
120
- export function serializeMcpOverlay(document: Record<string, unknown>): string {
121
- return `${JSON.stringify(document, null, 2)}\n`;
122
- }
@@ -1,64 +0,0 @@
1
- /**
2
- * ModelSelection: when a profile's model preset may take effect.
3
- *
4
- * The profile model is a PRESET, not an override. Pi's own explicit choices
5
- * win:
6
- *
7
- * 1. `--model` / `--thinking` on the command line.
8
- * 2. A model or thinking level recorded in the session history (a resumed
9
- * session restores the user's last choice).
10
- * 3. The profile's declaration.
11
- * 4. Pi's settings defaults.
12
- *
13
- * `/profile use` is the user choosing a profile deliberately, so it applies
14
- * the preset over 1 and 2.
15
- */
16
-
17
- import type { SessionEntry } from "@earendil-works/pi-coding-agent";
18
-
19
- import type { ProfileModel } from "./profile-catalog.ts";
20
- import type { ExplicitDeclarations } from "./startup-selection.ts";
21
-
22
- export interface SessionChoices {
23
- hasRecordedModel: boolean;
24
- hasRecordedThinking: boolean;
25
- }
26
-
27
- /** Reads whether the session history already states a model or thinking
28
- * level. Process startup is always `reason: "startup"`, so the recorded
29
- * entries — not the event reason — are what distinguishes a resumed
30
- * session from a fresh one. */
31
- export function readSessionChoices(entries: SessionEntry[]): SessionChoices {
32
- let hasRecordedModel = false;
33
- let hasRecordedThinking = false;
34
- for (const entry of entries) {
35
- if (entry.type === "model_change") hasRecordedModel = true;
36
- if (entry.type === "thinking_level_change") hasRecordedThinking = true;
37
- }
38
- return { hasRecordedModel, hasRecordedThinking };
39
- }
40
-
41
- export interface PresetDecisions {
42
- model: boolean;
43
- thinking: boolean;
44
- }
45
-
46
- /** Decides which halves of the preset may be applied. */
47
- export function decidePreset(input: {
48
- model: ProfileModel | undefined;
49
- explicit: ExplicitDeclarations;
50
- session: SessionChoices;
51
- /** True for an explicit `/profile use` (overrides 1 and 2). */
52
- force: boolean;
53
- }): PresetDecisions {
54
- if (input.model === undefined) {
55
- return { model: false, thinking: false };
56
- }
57
- if (input.force) {
58
- return { model: true, thinking: true };
59
- }
60
- return {
61
- model: !input.explicit.model && !input.session.hasRecordedModel,
62
- thinking: !input.explicit.thinking && !input.session.hasRecordedThinking,
63
- };
64
- }
@@ -1,50 +0,0 @@
1
- /**
2
- * NameMatching: the single implementation of literal/glob reference matching
3
- * and did-you-mean suggestions, shared by the resolver (selection) and the
4
- * prompt filter (visible skills).
5
- */
6
-
7
- import { minimatch } from "minimatch";
8
-
9
- export function isGlob(pattern: string): boolean {
10
- return pattern.includes("*") || pattern.includes("?") || pattern.includes("!");
11
- }
12
-
13
- export function matchesReference(pattern: string, candidate: string): boolean {
14
- return isGlob(pattern) ? minimatch(candidate, pattern) : candidate === pattern;
15
- }
16
-
17
- /** Levenshtein distance, used only for did-you-mean hints. */
18
- function editDistance(a: string, b: string): number {
19
- const rows = a.length + 1;
20
- const cols = b.length + 1;
21
- let previous = Array.from({ length: cols }, (_, index) => index);
22
- for (let row = 1; row < rows; row++) {
23
- const current = [row];
24
- for (let col = 1; col < cols; col++) {
25
- const cost = a[row - 1] === b[col - 1] ? 0 : 1;
26
- current[col] = Math.min(current[col - 1] + 1, previous[col] + 1, previous[col - 1] + cost);
27
- }
28
- previous = current;
29
- }
30
- return previous[cols - 1];
31
- }
32
-
33
- /** Up to three near-name suggestions for a literal reference. */
34
- export function suggestNames(reference: string, candidates: string[]): string[] {
35
- const lowered = reference.toLowerCase();
36
- const scored = candidates
37
- .map((candidate) => {
38
- const loweredCandidate = candidate.toLowerCase();
39
- const prefix = loweredCandidate.startsWith(lowered) || lowered.startsWith(loweredCandidate);
40
- const contains = loweredCandidate.includes(lowered) || lowered.includes(loweredCandidate);
41
- return { candidate, distance: editDistance(lowered, loweredCandidate), prefix, contains };
42
- })
43
- .filter((entry) => entry.prefix || entry.contains || entry.distance <= Math.max(2, Math.ceil(reference.length / 3)))
44
- .sort((a, b) => {
45
- if (a.prefix !== b.prefix) return a.prefix ? -1 : 1;
46
- if (a.contains !== b.contains) return a.contains ? -1 : 1;
47
- return a.distance - b.distance;
48
- });
49
- return scored.slice(0, 3).map((entry) => entry.candidate);
50
- }
@@ -1,142 +0,0 @@
1
- /**
2
- * Profile badge: the persistent, one-line answer to "which profile is this
3
- * session running?".
4
- *
5
- * Presentation only — no state, no I/O, no activation. The extension decides
6
- * when a badge is written (after an activation that succeeded, via
7
- * `ctx.ui.setStatus(PROFILE_STATUS_KEY, …)`), so the badge can never claim a
8
- * selection that was not applied.
9
- *
10
- * One canonical rendering, shared with the `/profile status` heading
11
- * (`profile: <name>`), plus `*` when a runtime overlay is in effect — the only
12
- * runtime difference the catalog does not show.
13
- */
14
-
15
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
16
-
17
- import { DEFAULT_PROFILE_NAME } from "./profile-catalog.ts";
18
-
19
- /**
20
- * Status key in Pi's footer. Pi joins all extension statuses into one line,
21
- * orders them by key, and truncates that line from the right — so an earlier
22
- * key keeps its text visible on a narrow terminal. `active-profile` sorts
23
- * before the keys it shares the line with (`mcp`, `pi-…`, `thinking`).
24
- */
25
- export const PROFILE_STATUS_KEY = "active-profile";
26
-
27
- /** The label prefix, matching the `/profile status` heading. */
28
- export const PROFILE_BADGE_LABEL = "profile";
29
-
30
- /**
31
- * Display columns reserved for the name before it is elided. The footer is a
32
- * shared, fixed-width line: profile names are unbounded user input, so an
33
- * unelided name would evict the statuses of other extensions.
34
- */
35
- export const PROFILE_BADGE_NAME_COLUMNS = 16;
36
-
37
- const ELLIPSIS = "…";
38
-
39
- export interface ProfileBadge {
40
- /** The name `/profile use` accepts (never the display `label`). */
41
- name: string;
42
- /** A runtime overlay is in effect: this runtime differs from the catalog. */
43
- overlay: boolean;
44
- }
45
-
46
- /** The minimum theme surface the badge needs; `ctx.ui.theme` satisfies it. */
47
- export interface BadgeTheme {
48
- fg(color: ThemeColor, text: string): string;
49
- }
50
-
51
- export interface BadgeOptions {
52
- overlay: boolean;
53
- /** Override the name budget (tests, future width awareness). */
54
- nameColumns?: number;
55
- }
56
-
57
- /**
58
- * Builds the badge for a resolved profile, or `undefined` when there must be
59
- * no badge at all.
60
- *
61
- * `default` is Pi's native baseline — it declares nothing — so it must not
62
- * change the footer either: a plain Pi session shows no badge, and the footer
63
- * status line only exists while some extension status is set.
64
- */
65
- export function buildProfileBadge(name: string, options: BadgeOptions): ProfileBadge | undefined {
66
- if (name === DEFAULT_PROFILE_NAME) return undefined;
67
- return {
68
- name: truncateToColumns(name, options.nameColumns ?? PROFILE_BADGE_NAME_COLUMNS),
69
- overlay: options.overlay,
70
- };
71
- }
72
-
73
- /**
74
- * Renders the badge. Colors come from the caller's theme at render time: Pi
75
- * stores footer statuses as finished strings, so the extension re-renders on
76
- * profile changes and on each turn (there is no extension-visible theme-change
77
- * event).
78
- */
79
- export function renderProfileBadge(badge: ProfileBadge, theme: BadgeTheme): string {
80
- const label = theme.fg("dim", `${PROFILE_BADGE_LABEL}: `);
81
- const name = theme.fg("dim", badge.name);
82
- return badge.overlay ? `${label}${name}${theme.fg("warning", "*")}` : `${label}${name}`;
83
- }
84
-
85
- /* -------------------------------------------------------------------------- */
86
- /* Display width */
87
- /* -------------------------------------------------------------------------- */
88
-
89
- /**
90
- * Width of one code point in terminal columns: 2 for East Asian wide and
91
- * fullwidth code points, 1 otherwise. Zero-width joiners and combining marks
92
- * are counted as 1 — an approximation that only over-reserves space for
93
- * exotic names.
94
- */
95
- export function codePointWidth(codePoint: number): number {
96
- return isWide(codePoint) ? 2 : 1;
97
- }
98
-
99
- /** Display width of `text` in terminal columns. */
100
- export function displayWidth(text: string): number {
101
- let width = 0;
102
- for (const character of text) width += codePointWidth(character.codePointAt(0)!);
103
- return width;
104
- }
105
-
106
- /**
107
- * Truncates `text` to at most `columns` terminal columns, appending `…` when
108
- * something was dropped. The ellipsis is part of the budget, so the result
109
- * never exceeds `columns`.
110
- */
111
- export function truncateToColumns(text: string, columns: number, ellipsis = ELLIPSIS): string {
112
- if (columns <= 0) return "";
113
- if (displayWidth(text) <= columns) return text;
114
- const budget = columns - displayWidth(ellipsis);
115
- let result = "";
116
- let width = 0;
117
- for (const character of text) {
118
- const next = width + codePointWidth(character.codePointAt(0)!);
119
- if (next > budget) break;
120
- result += character;
121
- width = next;
122
- }
123
- return budget < 0 ? "" : `${result}${ellipsis}`;
124
- }
125
-
126
- function isWide(codePoint: number): boolean {
127
- return (
128
- (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
129
- (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals, Kangxi, CJK symbols
130
- (codePoint >= 0x3041 && codePoint <= 0x33ff) || // kana, CJK compatibility, CJK punctuation
131
- (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK unified ideographs extension A
132
- (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK unified ideographs
133
- (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi syllables
134
- (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
135
- (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compatibility ideographs
136
- (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compatibility forms
137
- (codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
138
- (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
139
- (codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji, pictographs
140
- (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK unified ideographs extension B+
141
- );
142
- }
@@ -1,61 +0,0 @@
1
- /**
2
- * ProfilePresets: the starting points `/profile create` offers.
3
- *
4
- * A preset is DATA, not a profile. It never appears in `/profile list` and
5
- * cannot be activated until the create wizard copies it into a user catalog,
6
- * so `default` stays the only built-in profile and nothing here is ever
7
- * silently in effect. Once copied, the definition belongs to the user: the
8
- * preset is not tracked, and later changes to it do not reach existing
9
- * profiles.
10
- *
11
- * Every preset must work on a machine with no skills installed, no MCP
12
- * adapter, and no provider credentials:
13
- *
14
- * - no `mcp`: a declared MCP intent fails the whole activation when
15
- * `pi-mcp-adapter` is absent or the server was never discovered
16
- * (ADR-0002).
17
- * - no `model`: `provider` and `id` are required, and an unauthenticated
18
- * model also fails the whole activation.
19
- * - no `skills`: a literal reference warns once the skill turns out to be
20
- * missing, and `[]` hides every skill — omitting the field keeps Pi's full
21
- * visibility.
22
- * - `tools` names Pi's built-in tools only, because extension and MCP tool
23
- * names may never register, and it always contains `read`: Pi emits the
24
- * prompt's skills section only while `read` or `bash` is active.
25
- * - `instructions` states behavior, never a capability name, and stays
26
- * short: Pi appends it to the system prompt on every turn.
27
- *
28
- * `test/profile-presets.test.ts` enforces every rule above, asserts
29
- * `examples/profiles.json` is exactly this catalog, and asserts each preset
30
- * appears verbatim in `examples/profiles.example.json`.
31
- */
32
-
33
- import type { ProfileDefinition } from "./profile-catalog.ts";
34
-
35
- export interface ProfilePreset {
36
- /** Catalog key the create wizard offers as the new profile's name. */
37
- name: string;
38
- definition: ProfileDefinition;
39
- }
40
-
41
- /** The read-only behavior contract: describe, do not mutate. */
42
- const READ_ONLY_INSTRUCTIONS = [
43
- "Read-only session: inspect and report; never create, edit, rename, or delete files.",
44
- "If a change is needed, describe it in your reply instead of applying it.",
45
- "Do not run commands that modify state (installs, formatters, commits, pushes, network writes).",
46
- "Prefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.",
47
- "Ground claims in evidence: cite file:line and separate verified facts from inferences.",
48
- "Reply in English.",
49
- ].join("\n");
50
-
51
- export const PROFILE_PRESETS: readonly ProfilePreset[] = [
52
- {
53
- name: "read-only",
54
- definition: {
55
- label: "Read-only",
56
- description: "Read-only session; no skills or MCP servers assumed — add your own.",
57
- tools: ["read", "grep", "find", "ls"],
58
- instructions: READ_ONLY_INSTRUCTIONS,
59
- },
60
- },
61
- ];