@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.3
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 +29 -1
- package/dist/types/cli/dry-balance-cli.d.ts +104 -0
- package/dist/types/commands/dry-balance.d.ts +31 -0
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
- package/dist/types/hindsight/bank.d.ts +17 -9
- package/dist/types/hindsight/mental-models.d.ts +1 -1
- package/dist/types/hindsight/state.d.ts +9 -3
- package/dist/types/mcp/manager.d.ts +1 -1
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage.d.ts +2 -2
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tools/index.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +11 -0
- package/package.json +9 -9
- package/src/cli/dry-balance-cli.ts +823 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/dry-balance.ts +43 -0
- package/src/config/model-registry.ts +6 -0
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings.ts +38 -0
- package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
- package/src/discovery/github.ts +37 -1
- package/src/discovery/helpers.ts +3 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
- package/src/hindsight/backend.ts +184 -35
- package/src/hindsight/bank.ts +32 -22
- package/src/hindsight/mental-models.ts +1 -1
- package/src/hindsight/state.ts +21 -7
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/omp-protocol.ts +8 -2
- package/src/mcp/manager.ts +40 -21
- package/src/modes/components/transcript-container.ts +14 -3
- package/src/modes/components/tree-selector.ts +29 -2
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
- package/src/prompts/agents/explore.md +1 -0
- package/src/prompts/agents/librarian.md +1 -0
- package/src/prompts/dry-balance-bench.md +8 -0
- package/src/sdk.ts +82 -9
- package/src/session/agent-session.ts +66 -7
- package/src/session/auth-storage.ts +4 -0
- package/src/task/executor.ts +6 -2
- package/src/task/index.ts +8 -7
- package/src/task/types.ts +2 -0
- package/src/tools/bash.ts +3 -4
- package/src/tools/index.ts +16 -0
- package/src/tools/job.ts +3 -3
- package/src/tools/memory-reflect.ts +2 -2
- package/src/tools/path-utils.ts +21 -0
- package/src/tools/search.ts +18 -1
- package/src/utils/file-mentions.ts +7 -107
- package/src/utils/title-generator.ts +58 -37
package/src/cli-commands.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const commands: CommandEntry[] = [
|
|
|
20
20
|
{ name: "completions", load: () => import("./commands/completions").then(m => m.default) },
|
|
21
21
|
{ name: "__complete", load: () => import("./commands/complete").then(m => m.default) },
|
|
22
22
|
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
|
|
23
|
+
{ name: "dry-balance", load: () => import("./commands/dry-balance").then(m => m.default) },
|
|
23
24
|
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
|
|
24
25
|
{ name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
|
|
25
26
|
{ name: "install", load: () => import("./commands/install").then(m => m.default) },
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
|
2
|
+
import { runDryBalanceCommand } from "../cli/dry-balance-cli";
|
|
3
|
+
|
|
4
|
+
export default class DryBalance extends Command {
|
|
5
|
+
static description = "Dry-run OAuth account balancing across random session ids";
|
|
6
|
+
|
|
7
|
+
static args = {
|
|
8
|
+
model: Args.string({
|
|
9
|
+
description: "Model selector (provider/model or fuzzy id). Defaults to the configured default model.",
|
|
10
|
+
required: false,
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
static flags = {
|
|
15
|
+
model: Flags.string({ description: "Model selector (same syntax as --model on omp)" }),
|
|
16
|
+
count: Flags.integer({ description: "Number of random session ids to try", default: 100 }),
|
|
17
|
+
concurrency: Flags.integer({ description: "Maximum concurrent credential resolutions", default: 32 }),
|
|
18
|
+
json: Flags.boolean({ description: "Output JSON" }),
|
|
19
|
+
bench: Flags.boolean({ description: "Send one live benchmark request per OAuth account" }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
static examples = [
|
|
23
|
+
"# Dry-run the configured default model with 100 random session ids\n omp dry-balance",
|
|
24
|
+
"# Dry-run a specific model\n omp dry-balance anthropic/claude-sonnet-4-5",
|
|
25
|
+
"# Larger run with bounded concurrency\n omp dry-balance --model openai-codex/gpt-5-codex --count 1000 --concurrency 64",
|
|
26
|
+
"# Benchmark every OAuth account in parallel\n omp dry-balance --bench",
|
|
27
|
+
"# Machine-readable output\n omp dry-balance --json",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
async run(): Promise<void> {
|
|
31
|
+
const { args, flags } = await this.parse(DryBalance);
|
|
32
|
+
await runDryBalanceCommand({
|
|
33
|
+
model: args.model,
|
|
34
|
+
flags: {
|
|
35
|
+
model: flags.model,
|
|
36
|
+
count: flags.count,
|
|
37
|
+
concurrency: flags.concurrency,
|
|
38
|
+
json: flags.json,
|
|
39
|
+
bench: flags.bench,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -547,6 +547,7 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
|
|
|
547
547
|
if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
|
|
548
548
|
if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
|
|
549
549
|
if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
|
|
550
|
+
if (override.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = override.omitMaxOutputTokens;
|
|
550
551
|
if (override.contextPromotionTarget !== undefined) result.contextPromotionTarget = override.contextPromotionTarget;
|
|
551
552
|
if (override.premiumMultiplier !== undefined) result.premiumMultiplier = override.premiumMultiplier;
|
|
552
553
|
if (override.cost) {
|
|
@@ -575,6 +576,7 @@ interface CustomModelDefinitionLike {
|
|
|
575
576
|
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
576
577
|
contextWindow?: number;
|
|
577
578
|
maxTokens?: number;
|
|
579
|
+
omitMaxOutputTokens?: boolean;
|
|
578
580
|
headers?: Record<string, string>;
|
|
579
581
|
compat?: Model<Api>["compat"];
|
|
580
582
|
contextPromotionTarget?: string;
|
|
@@ -597,6 +599,7 @@ type CustomModelOverlay = {
|
|
|
597
599
|
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
598
600
|
contextWindow?: number;
|
|
599
601
|
maxTokens?: number;
|
|
602
|
+
omitMaxOutputTokens?: boolean;
|
|
600
603
|
headers?: Record<string, string>;
|
|
601
604
|
compat?: Model<Api>["compat"];
|
|
602
605
|
contextPromotionTarget?: string;
|
|
@@ -667,6 +670,7 @@ function buildCustomModelOverlay(
|
|
|
667
670
|
cost: modelDef.cost,
|
|
668
671
|
contextWindow: modelDef.contextWindow,
|
|
669
672
|
maxTokens: modelDef.maxTokens,
|
|
673
|
+
omitMaxOutputTokens: modelDef.omitMaxOutputTokens,
|
|
670
674
|
headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
|
|
671
675
|
compat: mergeCompat(providerCompat, modelDef.compat),
|
|
672
676
|
contextPromotionTarget: modelDef.contextPromotionTarget,
|
|
@@ -823,6 +827,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
|
|
|
823
827
|
resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : undefined),
|
|
824
828
|
maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : undefined),
|
|
825
829
|
headers: resolvedModel.headers,
|
|
830
|
+
omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
|
|
826
831
|
compat: mergeCompat(reference?.compat, resolvedModel.compat),
|
|
827
832
|
contextPromotionTarget: resolvedModel.contextPromotionTarget,
|
|
828
833
|
premiumMultiplier: resolvedModel.premiumMultiplier,
|
|
@@ -1124,6 +1129,7 @@ export class ModelRegistry {
|
|
|
1124
1129
|
cost: customModel.cost ?? existingModel.cost,
|
|
1125
1130
|
contextWindow: customModel.contextWindow ?? existingModel.contextWindow,
|
|
1126
1131
|
maxTokens: customModel.maxTokens ?? existingModel.maxTokens,
|
|
1132
|
+
omitMaxOutputTokens: customModel.omitMaxOutputTokens ?? existingModel.omitMaxOutputTokens,
|
|
1127
1133
|
// Same-id custom definitions replace bundled transport behavior. Provider-level
|
|
1128
1134
|
// headers/compat were already folded into customModel during parsing; do not
|
|
1129
1135
|
// re-merge bundled transport metadata here.
|
|
@@ -93,6 +93,7 @@ const ModelDefinitionSchema = z.object({
|
|
|
93
93
|
premiumMultiplier: z.number().optional(),
|
|
94
94
|
contextWindow: z.number().optional(),
|
|
95
95
|
maxTokens: z.number().optional(),
|
|
96
|
+
omitMaxOutputTokens: z.boolean().optional(),
|
|
96
97
|
headers: z.record(z.string(), z.string()).optional(),
|
|
97
98
|
compat: OpenAICompatSchema.optional(),
|
|
98
99
|
contextPromotionTarget: z.string().min(1).optional(),
|
|
@@ -114,6 +115,7 @@ export const ModelOverrideSchema = z.object({
|
|
|
114
115
|
premiumMultiplier: z.number().optional(),
|
|
115
116
|
contextWindow: z.number().optional(),
|
|
116
117
|
maxTokens: z.number().optional(),
|
|
118
|
+
omitMaxOutputTokens: z.boolean().optional(),
|
|
117
119
|
headers: z.record(z.string(), z.string()).optional(),
|
|
118
120
|
compat: OpenAICompatSchema.optional(),
|
|
119
121
|
contextPromotionTarget: z.string().min(1).optional(),
|
package/src/config/settings.ts
CHANGED
|
@@ -907,6 +907,9 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
|
|
|
907
907
|
for (const cb of appendOnlyModeCallbacks) cb(value);
|
|
908
908
|
}
|
|
909
909
|
},
|
|
910
|
+
"hindsight.bankId": () => fireHindsightScopeChanged(),
|
|
911
|
+
"hindsight.bankIdPrefix": () => fireHindsightScopeChanged(),
|
|
912
|
+
"hindsight.scoping": () => fireHindsightScopeChanged(),
|
|
910
913
|
};
|
|
911
914
|
/** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */
|
|
912
915
|
const appendOnlyModeCallbacks = new Set<(value: string) => void>();
|
|
@@ -923,6 +926,41 @@ export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void
|
|
|
923
926
|
};
|
|
924
927
|
}
|
|
925
928
|
|
|
929
|
+
/** Callbacks fired when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
|
|
930
|
+
const hindsightScopeCallbacks = new Set<() => void>();
|
|
931
|
+
|
|
932
|
+
function fireHindsightScopeChanged(): void {
|
|
933
|
+
// Snapshot the callback set before invoking — a callback's body is allowed
|
|
934
|
+
// to subscribe a NEW callback (the Hindsight backend re-registers the
|
|
935
|
+
// fresh state's listener on every rebuild). Iterating the live Set would
|
|
936
|
+
// re-invoke those just-added callbacks within the same fire, which spins
|
|
937
|
+
// in place: subscribe → invoke → subscribe → invoke → …
|
|
938
|
+
for (const cb of [...hindsightScopeCallbacks]) {
|
|
939
|
+
try {
|
|
940
|
+
cb();
|
|
941
|
+
} catch (err) {
|
|
942
|
+
logger.warn("Settings: hindsight scope hook failed", { error: String(err) });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Subscribe to changes in the Hindsight bank-scoping settings. Lets the
|
|
949
|
+
* Hindsight backend rebuild the active `HindsightSessionState` when the
|
|
950
|
+
* operator switches `hindsight.bankId`, `hindsight.bankIdPrefix`, or
|
|
951
|
+
* `hindsight.scoping` mid-session so subsequent retain/recall calls land in
|
|
952
|
+
* the new bank instead of the one selected at session start.
|
|
953
|
+
*
|
|
954
|
+
* Returns an unsubscribe function. The callback receives no arguments — the
|
|
955
|
+
* caller is expected to re-read the relevant settings via `Settings.get`.
|
|
956
|
+
*/
|
|
957
|
+
export function onHindsightScopeChanged(cb: () => void): () => void {
|
|
958
|
+
hindsightScopeCallbacks.add(cb);
|
|
959
|
+
return () => {
|
|
960
|
+
hindsightScopeCallbacks.delete(cb);
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
926
964
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
927
965
|
// Global Singleton
|
|
928
966
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -46,5 +46,6 @@ const doubled = value * 2;
|
|
|
46
46
|
- Callback identity matters.
|
|
47
47
|
- Type guard preserves narrowing.
|
|
48
48
|
- Public API, test seam, or DI boundary needs indirection.
|
|
49
|
+
- Names a non-obvious formula or magic-constant computation that the inlined expression would not explain on its own.
|
|
49
50
|
|
|
50
51
|
If none apply, inline it.
|
package/src/discovery/github.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Capabilities:
|
|
11
11
|
* - context-files: copilot-instructions.md in .github/
|
|
12
12
|
* - instructions: *.instructions.md in .github/instructions/ with applyTo frontmatter
|
|
13
|
+
* - skills: <name>/SKILL.md in .github/skills/ (GitHub Agent Skills layout)
|
|
13
14
|
*/
|
|
14
15
|
import * as path from "node:path";
|
|
15
16
|
import { parseFrontmatter } from "@oh-my-pi/pi-utils";
|
|
@@ -17,9 +18,10 @@ import { registerProvider } from "../capability";
|
|
|
17
18
|
import { type ContextFile, contextFileCapability } from "../capability/context-file";
|
|
18
19
|
import { readFile } from "../capability/fs";
|
|
19
20
|
import { type Instruction, instructionCapability } from "../capability/instruction";
|
|
21
|
+
import { type Skill, skillCapability } from "../capability/skill";
|
|
20
22
|
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
|
|
21
23
|
|
|
22
|
-
import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir } from "./helpers";
|
|
24
|
+
import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
|
|
23
25
|
|
|
24
26
|
const PROVIDER_ID = "github";
|
|
25
27
|
const DISPLAY_NAME = "GitHub Copilot";
|
|
@@ -97,6 +99,32 @@ function transformInstruction(name: string, content: string, filePath: string, s
|
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// Skills
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Load skills from `.github/skills/<name>/SKILL.md`.
|
|
108
|
+
*
|
|
109
|
+
* GitHub documents this layout for Copilot Agent Skills and matches the
|
|
110
|
+
* non-recursive shape `scanSkillsFromDir` already expects. `requireDescription`
|
|
111
|
+
* is on to match the Agent Skills spec (name + description are mandatory) and
|
|
112
|
+
* the sibling `native`/`omp-plugins` providers.
|
|
113
|
+
*
|
|
114
|
+
* @see https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills
|
|
115
|
+
*/
|
|
116
|
+
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
117
|
+
const skillsDir = getProjectPath(ctx, "github", "skills");
|
|
118
|
+
if (!skillsDir) return { items: [], warnings: [] };
|
|
119
|
+
|
|
120
|
+
return scanSkillsFromDir(ctx, {
|
|
121
|
+
dir: skillsDir,
|
|
122
|
+
providerId: PROVIDER_ID,
|
|
123
|
+
level: "project",
|
|
124
|
+
requireDescription: true,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
100
128
|
// =============================================================================
|
|
101
129
|
// Provider Registration
|
|
102
130
|
// =============================================================================
|
|
@@ -116,3 +144,11 @@ registerProvider(instructionCapability.id, {
|
|
|
116
144
|
priority: PRIORITY,
|
|
117
145
|
load: loadInstructions,
|
|
118
146
|
});
|
|
147
|
+
|
|
148
|
+
registerProvider<Skill>(skillCapability.id, {
|
|
149
|
+
id: PROVIDER_ID,
|
|
150
|
+
displayName: DISPLAY_NAME,
|
|
151
|
+
description: "Load skills from .github/skills/*/SKILL.md",
|
|
152
|
+
priority: PRIORITY,
|
|
153
|
+
load: loadSkills,
|
|
154
|
+
});
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -212,6 +212,7 @@ export interface ParsedAgentFields {
|
|
|
212
212
|
output?: unknown;
|
|
213
213
|
thinkingLevel?: ThinkingLevel;
|
|
214
214
|
autoloadSkills?: string[];
|
|
215
|
+
readSummarize?: boolean;
|
|
215
216
|
blocking?: boolean;
|
|
216
217
|
}
|
|
217
218
|
|
|
@@ -265,10 +266,11 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
|
|
|
265
266
|
const thinkingLevel = parseThinkingLevel(rawThinkingLevel);
|
|
266
267
|
const model = parseModelList(frontmatter.model);
|
|
267
268
|
const blocking = parseBoolean(frontmatter.blocking);
|
|
269
|
+
const readSummarize = parseBoolean(frontmatter.readSummarize);
|
|
268
270
|
const autoloadSkills = parseArrayOrCSV(frontmatter.autoloadSkills)
|
|
269
271
|
?.map(s => s.trim())
|
|
270
272
|
.filter(Boolean);
|
|
271
|
-
return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills };
|
|
273
|
+
return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills, readSummarize };
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
async function globIf(
|
|
@@ -41,10 +41,15 @@ const PI_SUBPATH_REMAPS: ReadonlyMap<string, string> = new Map<string, string>([
|
|
|
41
41
|
|
|
42
42
|
const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`);
|
|
43
43
|
const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp(
|
|
44
|
-
`((?:from\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
|
|
44
|
+
`((?:from\\s+|import\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
|
|
45
45
|
"g",
|
|
46
46
|
);
|
|
47
47
|
const resolvedSpecifierFallbacks = new Map<string, string>();
|
|
48
|
+
const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"] as const;
|
|
49
|
+
const SUPPORTED_PACKAGE_IMPORT_CONDITIONS = new Set(["bun", "node", "import", "default"]);
|
|
50
|
+
const packageRootCache = new Map<string, string | null>();
|
|
51
|
+
const packageImportsCache = new Map<string, Record<string, unknown> | null>();
|
|
52
|
+
const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
|
|
48
53
|
|
|
49
54
|
// Extensions that imported `@sinclair/typebox` directly used to resolve against a
|
|
50
55
|
// real `@sinclair/typebox` install. The runtime dep was replaced with the Zod-backed
|
|
@@ -221,33 +226,245 @@ function rewriteLegacyPiImports(source: string): string {
|
|
|
221
226
|
// Match the bare `@sinclair/typebox` import specifier (static + dynamic).
|
|
222
227
|
// Subpath imports like `@sinclair/typebox/compiler` are intentionally excluded —
|
|
223
228
|
// they expose TypeBox-only APIs the Zod-backed shim does not provide.
|
|
224
|
-
const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g;
|
|
229
|
+
const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g;
|
|
225
230
|
|
|
226
231
|
/**
|
|
227
|
-
* Rewrite the
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
232
|
+
* Rewrite the extension-owned specifiers OMP must host-resolve — legacy
|
|
233
|
+
* `@(scope)/pi-*`, bare `@sinclair/typebox`, and package `imports` aliases like
|
|
234
|
+
* `#src/*` — to absolute `file://` URLs. Every other specifier (relative
|
|
235
|
+
* siblings and third-party dependencies) is left untouched so Bun resolves it
|
|
231
236
|
* natively from the extension's real on-disk location.
|
|
232
237
|
*/
|
|
233
|
-
function rewriteLegacyExtensionSource(source: string): string {
|
|
238
|
+
async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
|
|
234
239
|
const withPi = rewriteLegacyPiImports(source);
|
|
235
|
-
|
|
240
|
+
const withTypeBox = withPi.replace(
|
|
236
241
|
TYPEBOX_IMPORT_SPECIFIER_REGEX,
|
|
237
242
|
(_match, prefix: string, _specifier: string, suffix: string) => {
|
|
238
243
|
return `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`;
|
|
239
244
|
},
|
|
240
245
|
);
|
|
246
|
+
return rewriteExtensionPackageImports(withTypeBox, importerPath);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
250
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
254
|
+
try {
|
|
255
|
+
await fs.stat(p);
|
|
256
|
+
return true;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function hasSourceModuleExtension(p: string): boolean {
|
|
263
|
+
const ext = path.extname(p).toLowerCase();
|
|
264
|
+
return (SOURCE_MODULE_EXTENSIONS as readonly string[]).includes(ext);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function resolveSourceModuleFile(basePath: string): Promise<string | null> {
|
|
268
|
+
try {
|
|
269
|
+
const stats = await fs.stat(basePath);
|
|
270
|
+
if (stats.isFile()) {
|
|
271
|
+
// Non-source files (JSON, WASM, text assets, etc.) bypass the on-load
|
|
272
|
+
// rewrite hook so Bun's native loaders handle them; our hook would
|
|
273
|
+
// otherwise pass them through `getLoader()` which falls back to `js`.
|
|
274
|
+
return hasSourceModuleExtension(basePath) ? realpathOrSelf(basePath) : null;
|
|
275
|
+
}
|
|
276
|
+
if (stats.isDirectory()) {
|
|
277
|
+
for (const extension of SOURCE_MODULE_EXTENSIONS) {
|
|
278
|
+
const resolved = await resolveSourceModuleFile(path.join(basePath, `index${extension}`));
|
|
279
|
+
if (resolved) return resolved;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
// Fall through to extension candidates below.
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (path.extname(basePath)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const extension of SOURCE_MODULE_EXTENSIONS) {
|
|
291
|
+
const resolved = await resolveSourceModuleFile(`${basePath}${extension}`);
|
|
292
|
+
if (resolved) return resolved;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function findPackageRoot(importerPath: string): Promise<string | null> {
|
|
298
|
+
let dir = path.dirname(importerPath);
|
|
299
|
+
while (true) {
|
|
300
|
+
const cached = packageRootCache.get(dir);
|
|
301
|
+
if (cached !== undefined) {
|
|
302
|
+
return cached;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (await pathExists(path.join(dir, "package.json"))) {
|
|
306
|
+
packageRootCache.set(path.dirname(importerPath), dir);
|
|
307
|
+
return dir;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const parent = path.dirname(dir);
|
|
311
|
+
if (parent === dir) {
|
|
312
|
+
packageRootCache.set(path.dirname(importerPath), null);
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
dir = parent;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function readPackageImports(packageRoot: string): Promise<Record<string, unknown> | null> {
|
|
320
|
+
const cached = packageImportsCache.get(packageRoot);
|
|
321
|
+
if (cached !== undefined) {
|
|
322
|
+
return cached;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let imports: Record<string, unknown> | null = null;
|
|
326
|
+
try {
|
|
327
|
+
const pkg = await Bun.file(path.join(packageRoot, "package.json")).json();
|
|
328
|
+
if (isRecord(pkg) && isRecord(pkg.imports)) {
|
|
329
|
+
imports = pkg.imports;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
imports = null;
|
|
333
|
+
}
|
|
334
|
+
packageImportsCache.set(packageRoot, imports);
|
|
335
|
+
return imports;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
type PackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED | null;
|
|
339
|
+
type ResolvedPackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED;
|
|
340
|
+
|
|
341
|
+
function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection {
|
|
342
|
+
if (entry === null) {
|
|
343
|
+
return PACKAGE_IMPORT_EXCLUDED;
|
|
344
|
+
}
|
|
345
|
+
if (typeof entry === "string") {
|
|
346
|
+
return entry;
|
|
347
|
+
}
|
|
348
|
+
if (Array.isArray(entry)) {
|
|
349
|
+
for (const item of entry) {
|
|
350
|
+
const target = selectPackageImportTarget(item);
|
|
351
|
+
if (target !== null) return target;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
if (!isRecord(entry)) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
for (const [condition, value] of Object.entries(entry)) {
|
|
359
|
+
if (!SUPPORTED_PACKAGE_IMPORT_CONDITIONS.has(condition)) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const target = selectPackageImportTarget(value);
|
|
363
|
+
if (target !== null) return target;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function resolvePackageImportTarget(
|
|
369
|
+
packageRoot: string,
|
|
370
|
+
target: string,
|
|
371
|
+
wildcard: string | null,
|
|
372
|
+
): Promise<string | null> {
|
|
373
|
+
if (!target.startsWith("./")) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
|
|
377
|
+
return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function resolvePackageImportSpecifier(specifier: string, importerPath: string): Promise<string | null> {
|
|
381
|
+
if (!specifier.startsWith("#")) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const packageRoot = await findPackageRoot(importerPath);
|
|
386
|
+
if (!packageRoot) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const imports = await readPackageImports(packageRoot);
|
|
391
|
+
if (!imports) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const exactTarget = selectPackageImportTarget(imports[specifier]);
|
|
396
|
+
if (exactTarget === PACKAGE_IMPORT_EXCLUDED) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if (exactTarget !== null) {
|
|
400
|
+
return resolvePackageImportTarget(packageRoot, exactTarget, null);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let bestMatch: { keyLength: number; target: ResolvedPackageImportTargetSelection; wildcard: string } | null = null;
|
|
404
|
+
for (const [key, entry] of Object.entries(imports)) {
|
|
405
|
+
const starIndex = key.indexOf("*");
|
|
406
|
+
if (starIndex === -1) continue;
|
|
407
|
+
|
|
408
|
+
const prefix = key.slice(0, starIndex);
|
|
409
|
+
const suffix = key.slice(starIndex + 1);
|
|
410
|
+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const target = selectPackageImportTarget(entry);
|
|
415
|
+
if (target === null) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!bestMatch || key.length > bestMatch.keyLength) {
|
|
420
|
+
bestMatch = {
|
|
421
|
+
keyLength: key.length,
|
|
422
|
+
target,
|
|
423
|
+
wildcard: specifier.slice(prefix.length, specifier.length - suffix.length),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!bestMatch || bestMatch.target === PACKAGE_IMPORT_EXCLUDED) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
return resolvePackageImportTarget(packageRoot, bestMatch.target, bestMatch.wildcard);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const PACKAGE_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(#[^"'()\s]+)(["'])/g;
|
|
435
|
+
|
|
436
|
+
async function rewriteExtensionPackageImports(source: string, importerPath: string): Promise<string> {
|
|
437
|
+
let rewritten = "";
|
|
438
|
+
let lastIndex = 0;
|
|
439
|
+
for (const match of source.matchAll(PACKAGE_IMPORT_SPECIFIER_REGEX)) {
|
|
440
|
+
const matchIndex = match.index;
|
|
441
|
+
if (matchIndex === undefined) continue;
|
|
442
|
+
|
|
443
|
+
const [fullMatch, prefix, specifier, suffix] = match;
|
|
444
|
+
if (!prefix || !specifier || !suffix) continue;
|
|
445
|
+
|
|
446
|
+
const resolved = await resolvePackageImportSpecifier(specifier, importerPath);
|
|
447
|
+
if (!resolved) continue;
|
|
448
|
+
|
|
449
|
+
rewritten += source.slice(lastIndex, matchIndex);
|
|
450
|
+
rewritten += `${prefix}${toImportSpecifier(resolved)}${suffix}`;
|
|
451
|
+
lastIndex = matchIndex + fullMatch.length;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (lastIndex === 0) {
|
|
455
|
+
return source;
|
|
456
|
+
}
|
|
457
|
+
return `${rewritten}${source.slice(lastIndex)}`;
|
|
241
458
|
}
|
|
242
459
|
|
|
243
460
|
function escapeRegExp(value: string): string {
|
|
244
461
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
245
462
|
}
|
|
246
463
|
|
|
247
|
-
// Match
|
|
248
|
-
// `
|
|
249
|
-
//
|
|
250
|
-
const
|
|
464
|
+
// Match source modules in an extension graph (relative imports and package
|
|
465
|
+
// `imports` aliases such as `#src/*`). Bare third-party dependencies remain
|
|
466
|
+
// native Bun resolutions.
|
|
467
|
+
const EXTENSION_GRAPH_SPECIFIER_REGEX = /(?:from\s+|import\s+|import\s*\(\s*)["']((?:\.\.?\/|#)[^"']+)["']/g;
|
|
251
468
|
|
|
252
469
|
// Extension entry realpaths that already have a load-time rewrite hook
|
|
253
470
|
// installed. Each `Bun.plugin()` registration is process-global and permanent,
|
|
@@ -287,10 +504,14 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
|
|
|
287
504
|
}
|
|
288
505
|
modules.add(file);
|
|
289
506
|
const dir = path.dirname(file);
|
|
290
|
-
for (const match of source.matchAll(
|
|
507
|
+
for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
|
|
508
|
+
const specifier = match[1];
|
|
509
|
+
if (!specifier) continue;
|
|
291
510
|
try {
|
|
292
|
-
const resolved =
|
|
293
|
-
|
|
511
|
+
const resolved = specifier.startsWith("#")
|
|
512
|
+
? await resolvePackageImportSpecifier(specifier, file)
|
|
513
|
+
: await realpathOrSelf(Bun.resolveSync(specifier, dir));
|
|
514
|
+
if (resolved && !modules.has(resolved)) {
|
|
294
515
|
queue.push(resolved);
|
|
295
516
|
}
|
|
296
517
|
} catch {
|
|
@@ -303,11 +524,12 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
|
|
|
303
524
|
|
|
304
525
|
/**
|
|
305
526
|
* Install a `Bun.plugin()` `onLoad` hook scoped to exactly the modules in an
|
|
306
|
-
* extension's
|
|
307
|
-
* `@sinclair/typebox
|
|
308
|
-
* cannot fall through (Bun requires a result object),
|
|
309
|
-
* exact-path alternation of the graph's realpaths — it
|
|
310
|
-
* other extensions, `node_modules` deps, or unrelated
|
|
527
|
+
* extension's source graph, so their legacy `@(scope)/pi-*`, bare
|
|
528
|
+
* `@sinclair/typebox`, and local package-import aliases are rewritten at load
|
|
529
|
+
* time. A runtime `onLoad` cannot fall through (Bun requires a result object),
|
|
530
|
+
* so the filter is an exact-path alternation of the graph's realpaths — it
|
|
531
|
+
* never matches the host, other extensions, `node_modules` deps, or unrelated
|
|
532
|
+
* project source.
|
|
311
533
|
*/
|
|
312
534
|
async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
|
|
313
535
|
if (hookedExtensionEntries.has(entryRealPath)) {
|
|
@@ -322,9 +544,8 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
|
|
|
322
544
|
name: `omp:legacy-pi-ext:${Bun.hash(entryRealPath).toString(36)}`,
|
|
323
545
|
setup(build) {
|
|
324
546
|
build.onLoad({ filter, namespace: "file" }, async args => {
|
|
325
|
-
// Re-read on every load so a `?mtime` reload picks up edited source.
|
|
326
547
|
const raw = await Bun.file(args.path).text();
|
|
327
|
-
return { contents: rewriteLegacyExtensionSource(raw), loader: getLoader(args.path) };
|
|
548
|
+
return { contents: await rewriteLegacyExtensionSource(raw, args.path), loader: getLoader(args.path) };
|
|
328
549
|
});
|
|
329
550
|
},
|
|
330
551
|
});
|
|
@@ -337,9 +558,8 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
|
|
|
337
558
|
* and `__dirname`-relative `readFileSync` asset loads (HTML/CSS bundled next to
|
|
338
559
|
* the entry) resolve exactly as they do under the original Pi runtime — no
|
|
339
560
|
* temp-directory mirroring and no asset copying. An `onLoad` hook scoped to the
|
|
340
|
-
* entry's
|
|
341
|
-
*
|
|
342
|
-
* resolves natively.
|
|
561
|
+
* entry's source graph rewrites only host-resolved compatibility imports in the
|
|
562
|
+
* extension's own source; everything else resolves natively.
|
|
343
563
|
*/
|
|
344
564
|
export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown> {
|
|
345
565
|
// Bun reports the realpath of a loaded module to `onLoad` and exposes it as
|