@oh-my-pi/pi-coding-agent 15.5.11 → 15.5.13

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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * OMP extension package roots.
3
+ *
4
+ * An "extension package root" is a directory configured via either
5
+ * `extensions:` in user/project settings or the `--extension`/`-e` CLI flag
6
+ * that points to a packaged extension on disk. The package's standard
7
+ * sub-directories (`skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
8
+ * `prompts/`, `.mcp.json`) are wired into discovery by `omp-plugins.ts`.
9
+ *
10
+ * CLI-provided paths are injected via {@link injectOmpExtensionCliRoots}
11
+ * before discovery runs; settings paths are read lazily from
12
+ * `<scope>/settings.json` in {@link listOmpExtensionRoots} to mirror what
13
+ * `loadExtensionModules` already does.
14
+ *
15
+ * @see ./omp-plugins.ts
16
+ * @see ./builtin.ts `loadExtensionModules`
17
+ */
18
+ import * as fs from "node:fs/promises";
19
+ import * as path from "node:path";
20
+ import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
21
+ import { readDirEntries, readFile } from "../capability/fs";
22
+ import type { LoadContext } from "../capability/types";
23
+ import { getEnabledPlugins } from "../extensibility/plugins/loader";
24
+ import { expandTilde } from "../tools/path-utils";
25
+
26
+ /** A resolved extension package directory wired into the discovery surfaces. */
27
+ export interface OmpExtensionRoot {
28
+ /** Absolute path to the package directory. */
29
+ path: string;
30
+ /** Stable display name (basename of the package directory). */
31
+ name: string;
32
+ /** Scope from which the path was sourced. */
33
+ level: "user" | "project";
34
+ }
35
+
36
+ interface InjectedRoot {
37
+ path: string;
38
+ level: "user" | "project";
39
+ }
40
+
41
+ let injectedCliRoots: InjectedRoot[] = [];
42
+
43
+ /**
44
+ * Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
45
+ * so the sub-discovery providers can find their sibling `skills/`, `hooks/`,
46
+ * etc. Paths that do not resolve to a directory are silently dropped — file
47
+ * entrypoints have no package sub-tree to scan.
48
+ *
49
+ * Call once during startup before any capability load. Repeated calls extend
50
+ * the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
51
+ */
52
+ export function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void {
53
+ if (paths.length === 0) return;
54
+ const expanded = paths.map(raw => {
55
+ const tilde = expandTilde(raw, home);
56
+ return path.isAbsolute(tilde) ? tilde : path.resolve(cwd, tilde);
57
+ });
58
+ const merged = new Map<string, InjectedRoot>();
59
+ for (const root of injectedCliRoots) merged.set(root.path, root);
60
+ for (const resolved of expanded) {
61
+ // CLI scope mirrors how `--extension` is treated elsewhere — user-level overrides win.
62
+ if (!merged.has(resolved)) merged.set(resolved, { path: resolved, level: "user" });
63
+ }
64
+ injectedCliRoots = [...merged.values()];
65
+ }
66
+
67
+ /** Drop every CLI-injected root. Tests use this between cases. */
68
+ export function clearOmpExtensionCliRoots(): void {
69
+ injectedCliRoots = [];
70
+ }
71
+
72
+ /** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
73
+ export function getInjectedOmpExtensionCliRoots(): readonly OmpExtensionRoot[] {
74
+ return injectedCliRoots.map(({ path: p, level }) => ({ path: p, level, name: path.basename(p) }));
75
+ }
76
+
77
+ interface ScopeDirs {
78
+ project: string;
79
+ user: string;
80
+ }
81
+
82
+ function scopeDirs(ctx: LoadContext): ScopeDirs {
83
+ return {
84
+ project: path.join(ctx.cwd, ".omp"),
85
+ user: path.join(ctx.home, ".omp", "agent"),
86
+ };
87
+ }
88
+
89
+ async function readSettingsExtensions(settingsPath: string): Promise<string[]> {
90
+ const content = await readFile(settingsPath);
91
+ if (!content) return [];
92
+ const parsed = tryParseJson<{ extensions?: unknown }>(content);
93
+ const raw = parsed?.extensions;
94
+ if (!Array.isArray(raw)) return [];
95
+ return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
96
+ }
97
+
98
+ function resolveAgainst(raw: string, ctx: LoadContext): string {
99
+ const tilde = expandTilde(raw, ctx.home);
100
+ return path.isAbsolute(tilde) ? tilde : path.resolve(ctx.cwd, tilde);
101
+ }
102
+
103
+ async function isDirectory(p: string): Promise<boolean> {
104
+ const entries = await readDirEntries(p);
105
+ if (entries.length > 0) return true;
106
+ // Empty directory still counts; cache returns [] for both empty and missing.
107
+ // Disambiguate with a single stat — only hit when the cached listing is empty.
108
+ try {
109
+ const stat = await fs.stat(p);
110
+ return stat.isDirectory();
111
+ } catch (err) {
112
+ if (isEnoent(err)) return false;
113
+ throw err;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Resolve every configured extension package directory for the given context.
119
+ *
120
+ * Sources, in order of precedence (later entries with the same absolute path
121
+ * are dropped):
122
+ *
123
+ * 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
124
+ * 2. Project `<cwd>/.omp/settings.json#extensions`
125
+ * 3. User `~/.omp/agent/settings.json#extensions`
126
+ * 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
127
+ * `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
128
+ *
129
+ * Only entries that resolve to a directory on disk are returned; file
130
+ * entrypoints contribute zero sub-discovery surface and are filtered out.
131
+ * Installed-plugin enumeration failures (missing lockfile, unreadable
132
+ * `package.json`, etc.) are logged at `debug` and degrade gracefully — the
133
+ * other sources still surface.
134
+ */
135
+ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]> {
136
+ const { project, user } = scopeDirs(ctx);
137
+ const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
138
+ readSettingsExtensions(path.join(project, "settings.json")),
139
+ readSettingsExtensions(path.join(user, "settings.json")),
140
+ listInstalledPluginRoots(ctx),
141
+ ]);
142
+
143
+ const candidates: InjectedRoot[] = [
144
+ ...injectedCliRoots,
145
+ ...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
146
+ ...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
147
+ ...installedPlugins,
148
+ ];
149
+
150
+ // First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence.
151
+ const seen = new Set<string>();
152
+ const unique: InjectedRoot[] = [];
153
+ for (const candidate of candidates) {
154
+ if (seen.has(candidate.path)) continue;
155
+ seen.add(candidate.path);
156
+ unique.push(candidate);
157
+ }
158
+
159
+ const directoryFlags = await Promise.all(unique.map(c => isDirectory(c.path)));
160
+ const roots: OmpExtensionRoot[] = [];
161
+ for (let i = 0; i < unique.length; i++) {
162
+ if (!directoryFlags[i]) continue;
163
+ const { path: p, level } = unique[i];
164
+ roots.push({ path: p, level, name: path.basename(p) });
165
+ }
166
+ return roots;
167
+ }
168
+
169
+ /**
170
+ * Enumerate every enabled installed plugin's package directory so its
171
+ * conventional `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
172
+ * `prompts/`, and `.mcp.json` are wired into discovery — mirrors how
173
+ * `getAllPluginExtensionPaths` already feeds the extension factory loader.
174
+ *
175
+ * Marketplace and `omp plugin link` installs write to the plugin manager's
176
+ * `node_modules` (or symlink into it) rather than to `extensions:` in
177
+ * settings; without this branch the sub-discovery provider would still miss
178
+ * everything those install paths produce.
179
+ */
180
+ async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
181
+ try {
182
+ const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
183
+ // Installed plugins are always user-scope; project disablement is already
184
+ // honored by `getEnabledPlugins` via `loadProjectOverrides`.
185
+ return plugins.map(({ path: p }) => ({ path: p, level: "user" }));
186
+ } catch (err) {
187
+ logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
188
+ return [];
189
+ }
190
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * OMP extension-package sub-discovery provider.
3
+ *
4
+ * When a user configures an extension via `extensions:` (in settings) or
5
+ * `--extension`/`-e` (on the CLI), the docs promise that the package's
6
+ * sibling directories — `skills/`, `hooks/pre|post/`, `tools/`, `commands/`,
7
+ * `rules/`, `prompts/`, and `.mcp.json` — are picked up by omp's standard
8
+ * discovery surfaces. The native `omp` provider in `builtin.ts` only walks
9
+ * `.omp/` and `~/.omp/agent/`, so without this provider those sub-trees are
10
+ * silently ignored.
11
+ *
12
+ * Provider priority is set below the native `omp` provider (100) so an
13
+ * extension package never shadows the user's own `.omp/` configuration on
14
+ * dedup.
15
+ *
16
+ * @see ./omp-extension-roots.ts
17
+ * @see ../../docs/extension-loading.md
18
+ */
19
+ import * as path from "node:path";
20
+ import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
21
+ import { registerProvider } from "../capability";
22
+ import { readDirEntries, readFile } from "../capability/fs";
23
+ import { type Hook, hookCapability } from "../capability/hook";
24
+ import { type MCPServer, mcpCapability } from "../capability/mcp";
25
+ import { type Prompt, promptCapability } from "../capability/prompt";
26
+ import { type Rule, ruleCapability } from "../capability/rule";
27
+ import { type Skill, skillCapability } from "../capability/skill";
28
+ import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
29
+ import { type CustomTool, toolCapability } from "../capability/tool";
30
+ import type { LoadContext, LoadResult } from "../capability/types";
31
+ import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
32
+ import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
33
+
34
+ const PROVIDER_ID = "omp-plugins";
35
+ const DISPLAY_NAME = "OMP Extension Packages";
36
+ const DESCRIPTION =
37
+ "Sub-discovery (skills, hooks, tools, commands, rules, prompts, .mcp.json) inside extension packages";
38
+ const PRIORITY = 90;
39
+
40
+ // =============================================================================
41
+ // Skills
42
+ // =============================================================================
43
+
44
+ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
45
+ const roots = await listOmpExtensionRoots(ctx);
46
+ const results = await Promise.all(
47
+ roots.map(root =>
48
+ scanSkillsFromDir(ctx, {
49
+ dir: path.join(root.path, "skills"),
50
+ providerId: PROVIDER_ID,
51
+ level: root.level,
52
+ requireDescription: true,
53
+ }),
54
+ ),
55
+ );
56
+ return {
57
+ items: results.flatMap(r => r.items),
58
+ warnings: results.flatMap(r => r.warnings ?? []),
59
+ };
60
+ }
61
+
62
+ // =============================================================================
63
+ // Slash Commands
64
+ // =============================================================================
65
+
66
+ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
67
+ const roots = await listOmpExtensionRoots(ctx);
68
+ const results = await Promise.all(
69
+ roots.map(root =>
70
+ loadFilesFromDir<SlashCommand>(ctx, path.join(root.path, "commands"), PROVIDER_ID, root.level, {
71
+ extensions: ["md"],
72
+ transform: (name, content, filePath, source) => ({
73
+ name: name.replace(/\.md$/, ""),
74
+ path: filePath,
75
+ content,
76
+ level: root.level,
77
+ _source: source,
78
+ }),
79
+ }),
80
+ ),
81
+ );
82
+ return {
83
+ items: results.flatMap(r => r.items),
84
+ warnings: results.flatMap(r => r.warnings ?? []),
85
+ };
86
+ }
87
+
88
+ // =============================================================================
89
+ // Rules
90
+ // =============================================================================
91
+
92
+ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
93
+ const roots = await listOmpExtensionRoots(ctx);
94
+ const results = await Promise.all(
95
+ roots.map(root =>
96
+ loadFilesFromDir<Rule>(ctx, path.join(root.path, "rules"), PROVIDER_ID, root.level, {
97
+ extensions: ["md", "mdc"],
98
+ transform: (name, content, filePath, source) =>
99
+ buildRuleFromMarkdown(name, content, filePath, source, { stripNamePattern: /\.(md|mdc)$/ }),
100
+ }),
101
+ ),
102
+ );
103
+ return {
104
+ items: results.flatMap(r => r.items),
105
+ warnings: results.flatMap(r => r.warnings ?? []),
106
+ };
107
+ }
108
+
109
+ // =============================================================================
110
+ // Prompts
111
+ // =============================================================================
112
+
113
+ async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
114
+ const roots = await listOmpExtensionRoots(ctx);
115
+ const results = await Promise.all(
116
+ roots.map(root =>
117
+ loadFilesFromDir<Prompt>(ctx, path.join(root.path, "prompts"), PROVIDER_ID, root.level, {
118
+ extensions: ["md"],
119
+ transform: (name, content, filePath, source) => ({
120
+ name: name.replace(/\.md$/, ""),
121
+ path: filePath,
122
+ content,
123
+ _source: source,
124
+ }),
125
+ }),
126
+ ),
127
+ );
128
+ return {
129
+ items: results.flatMap(r => r.items),
130
+ warnings: results.flatMap(r => r.warnings ?? []),
131
+ };
132
+ }
133
+
134
+ // =============================================================================
135
+ // Hooks
136
+ // =============================================================================
137
+
138
+ const HOOK_TYPES: ReadonlyArray<"pre" | "post"> = ["pre", "post"];
139
+
140
+ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
141
+ const roots = await listOmpExtensionRoots(ctx);
142
+ const tasks: Array<{ root: OmpExtensionRoot; hookType: "pre" | "post" }> = [];
143
+ for (const root of roots) {
144
+ for (const hookType of HOOK_TYPES) {
145
+ tasks.push({ root, hookType });
146
+ }
147
+ }
148
+ const results = await Promise.all(
149
+ tasks.map(({ root, hookType }) =>
150
+ loadFilesFromDir<Hook>(ctx, path.join(root.path, "hooks", hookType), PROVIDER_ID, root.level, {
151
+ transform: (name, _content, filePath, source) => {
152
+ const baseName = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name;
153
+ const tool = baseName === "*" ? "*" : baseName;
154
+ return {
155
+ name,
156
+ path: filePath,
157
+ type: hookType,
158
+ tool,
159
+ level: root.level,
160
+ _source: source,
161
+ };
162
+ },
163
+ }),
164
+ ),
165
+ );
166
+ return {
167
+ items: results.flatMap(r => r.items),
168
+ warnings: results.flatMap(r => r.warnings ?? []),
169
+ };
170
+ }
171
+
172
+ // =============================================================================
173
+ // Custom Tools
174
+ // =============================================================================
175
+
176
+ const TOOL_EXTENSIONS = ["json", "md", "ts", "js", "sh", "bash", "py"];
177
+
178
+ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
179
+ const roots = await listOmpExtensionRoots(ctx);
180
+ const perRoot = await Promise.all(
181
+ roots.map(async root => {
182
+ const toolsDir = path.join(root.path, "tools");
183
+ const [filesResult, entries] = await Promise.all([
184
+ loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, root.level, {
185
+ extensions: TOOL_EXTENSIONS,
186
+ transform: (name, content, filePath, source) => {
187
+ if (name.endsWith(".json")) {
188
+ const data = tryParseJson<{ name?: string; description?: string }>(content);
189
+ const toolName = data?.name || name.replace(/\.json$/, "");
190
+ const description =
191
+ typeof data?.description === "string" && data.description.trim()
192
+ ? data.description
193
+ : `${toolName} custom tool`;
194
+ return { name: toolName, path: filePath, description, level: root.level, _source: source };
195
+ }
196
+ if (name.endsWith(".md")) {
197
+ const { frontmatter } = parseFrontmatter(content, { source: filePath });
198
+ const toolName = (frontmatter.name as string) || name.replace(/\.md$/, "");
199
+ const description =
200
+ typeof frontmatter.description === "string" && frontmatter.description.trim()
201
+ ? String(frontmatter.description)
202
+ : `${toolName} custom tool`;
203
+ return { name: toolName, path: filePath, description, level: root.level, _source: source };
204
+ }
205
+ const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
206
+ return {
207
+ name: toolName,
208
+ path: filePath,
209
+ description: `${toolName} custom tool`,
210
+ level: root.level,
211
+ _source: source,
212
+ };
213
+ },
214
+ }),
215
+ readDirEntries(toolsDir),
216
+ ]);
217
+
218
+ // `<tools>/<name>/index.ts` sub-directory tools, mirroring `builtin.ts:loadTools`.
219
+ const indexCandidates = entries
220
+ .filter(e => !e.name.startsWith(".") && e.isDirectory())
221
+ .map(e => path.join(toolsDir, e.name, "index.ts"));
222
+ const indexContents = await Promise.all(indexCandidates.map(p => readFile(p)));
223
+ const indexItems: CustomTool[] = [];
224
+ for (let i = 0; i < indexCandidates.length; i++) {
225
+ if (indexContents[i] === null) continue;
226
+ const indexPath = indexCandidates[i];
227
+ const toolName = path.basename(path.dirname(indexPath));
228
+ indexItems.push({
229
+ name: toolName,
230
+ path: indexPath,
231
+ description: `${toolName} custom tool`,
232
+ level: root.level,
233
+ _source: createSourceMeta(PROVIDER_ID, indexPath, root.level),
234
+ });
235
+ }
236
+
237
+ return { filesResult, indexItems };
238
+ }),
239
+ );
240
+
241
+ const items: CustomTool[] = [];
242
+ const warnings: string[] = [];
243
+ for (const { filesResult, indexItems } of perRoot) {
244
+ items.push(...filesResult.items, ...indexItems);
245
+ if (filesResult.warnings) warnings.push(...filesResult.warnings);
246
+ }
247
+ return { items, warnings };
248
+ }
249
+
250
+ // =============================================================================
251
+ // MCP Servers
252
+ // =============================================================================
253
+
254
+ const MCP_FILENAMES = [".mcp.json", "mcp.json"] as const;
255
+
256
+ interface RawMcpServer {
257
+ enabled?: boolean;
258
+ timeout?: number;
259
+ command?: string;
260
+ args?: string[];
261
+ env?: Record<string, string>;
262
+ cwd?: string;
263
+ url?: string;
264
+ headers?: Record<string, string>;
265
+ auth?: MCPServer["auth"];
266
+ oauth?: MCPServer["oauth"];
267
+ type?: MCPServer["transport"];
268
+ }
269
+
270
+ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
271
+ const roots = await listOmpExtensionRoots(ctx);
272
+ const items: MCPServer[] = [];
273
+ const warnings: string[] = [];
274
+
275
+ const tasks: Array<{ root: OmpExtensionRoot; mcpPath: string }> = [];
276
+ for (const root of roots) {
277
+ for (const filename of MCP_FILENAMES) {
278
+ tasks.push({ root, mcpPath: path.join(root.path, filename) });
279
+ }
280
+ }
281
+ const contents = await Promise.all(tasks.map(({ mcpPath }) => readFile(mcpPath)));
282
+
283
+ for (let i = 0; i < tasks.length; i++) {
284
+ const raw = contents[i];
285
+ if (raw === null) continue;
286
+ const { root, mcpPath } = tasks[i];
287
+
288
+ const parsed = tryParseJson<{ mcpServers?: Record<string, unknown> }>(raw);
289
+ if (!parsed) {
290
+ warnings.push(`[omp-plugins] Invalid JSON in ${mcpPath}`);
291
+ logger.warn(`[omp-plugins] Invalid JSON in ${mcpPath}`);
292
+ continue;
293
+ }
294
+ const servers = parsed.mcpServers;
295
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) continue;
296
+
297
+ for (const [serverName, serverCfg] of Object.entries(servers)) {
298
+ if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
299
+ const cfg = serverCfg as RawMcpServer;
300
+ if (typeof cfg.command !== "string" && typeof cfg.url !== "string") {
301
+ warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
302
+ continue;
303
+ }
304
+ items.push({
305
+ name: serverName,
306
+ ...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
307
+ ...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
308
+ ...(cfg.command !== undefined && { command: cfg.command }),
309
+ ...(cfg.args !== undefined && { args: cfg.args }),
310
+ ...(cfg.env !== undefined && { env: cfg.env }),
311
+ ...(cfg.cwd !== undefined && { cwd: cfg.cwd }),
312
+ ...(cfg.url !== undefined && { url: cfg.url }),
313
+ ...(cfg.headers !== undefined && { headers: cfg.headers }),
314
+ ...(cfg.auth !== undefined && { auth: cfg.auth }),
315
+ ...(cfg.oauth !== undefined && { oauth: cfg.oauth }),
316
+ ...(cfg.type !== undefined && { transport: cfg.type }),
317
+ _source: createSourceMeta(PROVIDER_ID, mcpPath, root.level),
318
+ });
319
+ }
320
+ }
321
+
322
+ return { items, warnings };
323
+ }
324
+
325
+ // =============================================================================
326
+ // Provider Registration
327
+ // =============================================================================
328
+
329
+ registerProvider<Skill>(skillCapability.id, {
330
+ id: PROVIDER_ID,
331
+ displayName: DISPLAY_NAME,
332
+ description: DESCRIPTION,
333
+ priority: PRIORITY,
334
+ load: loadSkills,
335
+ });
336
+
337
+ registerProvider<SlashCommand>(slashCommandCapability.id, {
338
+ id: PROVIDER_ID,
339
+ displayName: DISPLAY_NAME,
340
+ description: DESCRIPTION,
341
+ priority: PRIORITY,
342
+ load: loadSlashCommands,
343
+ });
344
+
345
+ registerProvider<Rule>(ruleCapability.id, {
346
+ id: PROVIDER_ID,
347
+ displayName: DISPLAY_NAME,
348
+ description: DESCRIPTION,
349
+ priority: PRIORITY,
350
+ load: loadRules,
351
+ });
352
+
353
+ registerProvider<Prompt>(promptCapability.id, {
354
+ id: PROVIDER_ID,
355
+ displayName: DISPLAY_NAME,
356
+ description: DESCRIPTION,
357
+ priority: PRIORITY,
358
+ load: loadPrompts,
359
+ });
360
+
361
+ registerProvider<Hook>(hookCapability.id, {
362
+ id: PROVIDER_ID,
363
+ displayName: DISPLAY_NAME,
364
+ description: DESCRIPTION,
365
+ priority: PRIORITY,
366
+ load: loadHooks,
367
+ });
368
+
369
+ registerProvider<CustomTool>(toolCapability.id, {
370
+ id: PROVIDER_ID,
371
+ displayName: DISPLAY_NAME,
372
+ description: DESCRIPTION,
373
+ priority: PRIORITY,
374
+ load: loadTools,
375
+ });
376
+
377
+ registerProvider<MCPServer>(mcpCapability.id, {
378
+ id: PROVIDER_ID,
379
+ displayName: DISPLAY_NAME,
380
+ description: DESCRIPTION,
381
+ priority: PRIORITY,
382
+ load: loadMCPServers,
383
+ });
@@ -9,6 +9,15 @@
9
9
  * is wiring it onto the per-session owner object.
10
10
  */
11
11
  import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
12
+ import { normalizeToLF } from "./normalize";
13
+
14
+ /**
15
+ * Upper bound on the file size we snapshot. A section tag is a content hash of
16
+ * the *whole* file, so minting one means holding the full normalized text in
17
+ * the store. Files above this cap emit no `¶path#tag` header — line-anchored
18
+ * editing of multi-megabyte files is out of scope under the full-content model.
19
+ */
20
+ export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
12
21
 
13
22
  interface FileSnapshotStoreOwner {
14
23
  fileSnapshotStore?: InMemorySnapshotStore;
@@ -23,3 +32,28 @@ export function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemoryS
23
32
  if (!session.fileSnapshotStore) session.fileSnapshotStore = new InMemorySnapshotStore();
24
33
  return session.fileSnapshotStore;
25
34
  }
35
+
36
+ /**
37
+ * Read the full text of `absolutePath` (within {@link SNAPSHOT_MAX_BYTES}),
38
+ * record it as a version snapshot, and return its content-hash tag. Returns
39
+ * `undefined` when the file exceeds the cap or cannot be read — callers then
40
+ * omit the section header so the model never sees a tag it can't anchor against.
41
+ *
42
+ * Producers that only displayed a slice of the file (range reads, search hits)
43
+ * use this to mint a whole-file tag: the displayed lines stay partial, but the
44
+ * tag fingerprints the entire file so a follow-up edit anchored at any line
45
+ * validates whenever the live file is byte-identical to what was read.
46
+ */
47
+ export async function recordFileSnapshot(
48
+ session: FileSnapshotStoreOwner,
49
+ absolutePath: string,
50
+ ): Promise<string | undefined> {
51
+ try {
52
+ const file = Bun.file(absolutePath);
53
+ if (file.size > SNAPSHOT_MAX_BYTES) return undefined;
54
+ const normalized = normalizeToLF(await file.text());
55
+ return getFileSnapshotStore(session).record(absolutePath, normalized);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
@@ -44,14 +44,9 @@ function hasAnchorScoped(section: PatchSection): boolean {
44
44
  return section.hasAnchorScopedEdit;
45
45
  }
46
46
 
47
- function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string, anchorLines: readonly number[]): boolean {
48
- if (snapshot.fullText !== undefined) return snapshot.fullText === currentText;
49
- for (const lineNumber of anchorLines) {
50
- if (snapshot.get(lineNumber) === undefined) return false;
51
- }
52
- return snapshot.matchesLiveFile(currentText.split("\n"));
47
+ function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string): boolean {
48
+ return snapshot.text === currentText;
53
49
  }
54
-
55
50
  function validateSectionHash(
56
51
  section: PatchSection,
57
52
  absolutePath: string,
@@ -64,7 +59,7 @@ function validateSectionHash(
64
59
  : null;
65
60
  }
66
61
  const snapshot = snapshots.byHash(absolutePath, section.fileHash);
67
- if (snapshot && snapshotMatchesCurrent(snapshot, text, section.collectAnchorLines())) return null;
62
+ if (snapshot && snapshotMatchesCurrent(snapshot, text)) return null;
68
63
  return `Hashline snapshot tag mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file does not match that snapshot; re-read and try again.`;
69
64
  }
70
65
 
@@ -312,7 +312,7 @@ const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** E
312
312
 
313
313
  function normalizeHashlineInputPreviewPath(rawPath: string): string {
314
314
  const trimmed = rawPath.trim();
315
- const hashStart = /#[0-9a-fA-F]{3}$/u.exec(trimmed)?.index;
315
+ const hashStart = /#[0-9a-fA-F]{4}$/u.exec(trimmed)?.index;
316
316
  const withoutHash = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
317
317
  if (withoutHash.length < 2) return withoutHash;
318
318
  const first = withoutHash[0];