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.
- package/README.md +31 -36
- package/README.zh-CN.md +31 -36
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -13
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +45 -93
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -41
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +14 -26
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -778
- package/src/adapter-presence.ts +0 -75
- package/src/default-profiles.ts +0 -59
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -271
- package/src/startup-selection.ts +0 -201
- package/src/switching/activate-profile.ts +0 -144
- package/src/switching/apply-profile.ts +0 -131
package/src/adapter-presence.ts
DELETED
|
@@ -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
|
-
}
|
package/src/default-profiles.ts
DELETED
|
@@ -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
|
-
}
|
package/src/mcp-overlay-file.ts
DELETED
|
@@ -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
|
-
}
|
package/src/mcp-overlay.ts
DELETED
|
@@ -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
|
-
}
|
package/src/model-selection.ts
DELETED
|
@@ -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
|
-
}
|
package/src/name-matching.ts
DELETED
|
@@ -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
|
-
}
|
package/src/profile-badge.ts
DELETED
|
@@ -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
|
-
}
|
package/src/profile-presets.ts
DELETED
|
@@ -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
|
-
];
|