@oh-my-pi/pi-coding-agent 13.12.0 → 13.12.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 +37 -0
- package/package.json +7 -7
- package/src/capability/context-file.ts +2 -0
- package/src/capability/extension-module.ts +1 -0
- package/src/capability/hook.ts +1 -0
- package/src/capability/index.ts +21 -10
- package/src/capability/instruction.ts +1 -0
- package/src/capability/mcp.ts +1 -0
- package/src/capability/prompt.ts +1 -0
- package/src/capability/rule.ts +1 -0
- package/src/capability/skill.ts +1 -0
- package/src/capability/slash-command.ts +1 -0
- package/src/capability/tool.ts +1 -0
- package/src/capability/types.ts +10 -0
- package/src/cli/commands/init-xdg.ts +2 -2
- package/src/cli/config-cli.ts +1 -1
- package/src/config/settings-schema.ts +42 -0
- package/src/extensibility/extensions/loader.ts +5 -1
- package/src/extensibility/plugins/loader.ts +23 -5
- package/src/extensibility/plugins/manager.ts +14 -0
- package/src/extensibility/plugins/types.ts +4 -0
- package/src/extensibility/skills.ts +7 -1
- package/src/ipy/kernel.ts +4 -5
- package/src/modes/components/diff.ts +2 -7
- package/src/modes/components/extensions/state-manager.ts +3 -2
- package/src/modes/components/settings-defs.ts +55 -5
- package/src/modes/components/settings-selector.ts +5 -0
- package/src/modes/controllers/command-controller.ts +8 -2
- package/src/patch/diff.ts +9 -1
- package/src/patch/index.ts +56 -9
- package/src/sdk.ts +6 -1
- package/src/session/compaction/compaction.ts +10 -0
- package/src/session/session-manager.ts +13 -5
- package/src/tools/output-meta.ts +15 -8
- package/src/tools/path-utils.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [13.12.2] - 2026-03-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `compaction.thresholdTokens` setting as a fixed token limit alternative to percentage-based compaction threshold
|
|
10
|
+
- Added more artifact spill threshold options (1 KB to 1 MB) with size descriptions
|
|
11
|
+
- Added more artifact tail bytes and tail lines options with descriptions
|
|
12
|
+
- Added `toExtensionId` capability method to enable granular disabling of individual capabilities by ID
|
|
13
|
+
- Added support for disabling specific capabilities (skills, tools, hooks, rules, prompts, instructions, slash commands, MCP servers, extension modules, and context files) via `disabledExtensions` setting
|
|
14
|
+
- Added `includeDisabled` and `disabledExtensions` options to `LoadOptions` for capability loading
|
|
15
|
+
- Added plugin manifest support for `extensions` entry points to allow plugins to contribute extension modules
|
|
16
|
+
- Added `extensions` field to plugin features for feature-specific extension entry points
|
|
17
|
+
- Added automatic discovery of extension modules from installed plugins during extension loading
|
|
18
|
+
- Added `disabledExtensions` setting to allow disabling specific extensions and skills by ID
|
|
19
|
+
- Added support for filtering skills by disabled extension IDs with `skill:` prefix
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Changed capability loading to filter out disabled items based on extension IDs before returning results
|
|
24
|
+
- Changed plugin loader to support `extensions` as a manifest entry type alongside tools, hooks, and commands
|
|
25
|
+
- Changed extension discovery to include extension entry points from all enabled plugins
|
|
26
|
+
- Changed context file path handling to use `path.basename()` for consistent cross-platform filename extraction
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Fixed skill loading to properly respect disabled skill names when loading from custom directories
|
|
31
|
+
|
|
32
|
+
## [13.12.1] - 2026-03-15
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Support for move-only operations that preserve exact bytes including binary files
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Fixed handling of file moves when no edits are specified, now correctly preserves binary content
|
|
40
|
+
- Fixed validation to reject move operations where source and destination paths are identical
|
|
41
|
+
|
|
5
42
|
## [13.12.0] - 2026-03-14
|
|
6
43
|
|
|
7
44
|
### Added
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "13.12.
|
|
4
|
+
"version": "13.12.3",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@mozilla/readability": "^0.6",
|
|
44
|
-
"@oh-my-pi/omp-stats": "13.12.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "13.12.
|
|
46
|
-
"@oh-my-pi/pi-ai": "13.12.
|
|
47
|
-
"@oh-my-pi/pi-natives": "13.12.
|
|
48
|
-
"@oh-my-pi/pi-tui": "13.12.
|
|
49
|
-
"@oh-my-pi/pi-utils": "13.12.
|
|
44
|
+
"@oh-my-pi/omp-stats": "13.12.3",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "13.12.3",
|
|
46
|
+
"@oh-my-pi/pi-ai": "13.12.3",
|
|
47
|
+
"@oh-my-pi/pi-natives": "13.12.3",
|
|
48
|
+
"@oh-my-pi/pi-tui": "13.12.3",
|
|
49
|
+
"@oh-my-pi/pi-utils": "13.12.3",
|
|
50
50
|
"@sinclair/typebox": "^0.34",
|
|
51
51
|
"@xterm/headless": "^6.0",
|
|
52
52
|
"ajv": "^8.18",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* System instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, etc.) that provide
|
|
5
5
|
* persistent guidance to the agent.
|
|
6
6
|
*/
|
|
7
|
+
import * as path from "node:path";
|
|
7
8
|
import { defineCapability } from ".";
|
|
8
9
|
import type { SourceMeta } from "./types";
|
|
9
10
|
|
|
@@ -33,6 +34,7 @@ export const contextFileCapability = defineCapability<ContextFile>({
|
|
|
33
34
|
// Clamp depth >= 0: files inside config subdirectories of an ancestor (e.g. .claude/, .github/)
|
|
34
35
|
// are same-scope as the ancestor itself.
|
|
35
36
|
key: file => (file.level === "user" ? "user" : `project:${Math.max(0, file.depth ?? 0)}`),
|
|
37
|
+
toExtensionId: file => `context-file:${file.level}:${path.basename(file.path)}`,
|
|
36
38
|
validate: file => {
|
|
37
39
|
if (!file.path) return "Missing path";
|
|
38
40
|
if (file.content === undefined) return "Missing content";
|
|
@@ -25,6 +25,7 @@ export const extensionModuleCapability = defineCapability<ExtensionModule>({
|
|
|
25
25
|
displayName: "Extension Modules",
|
|
26
26
|
description: "TypeScript/JavaScript extension modules loaded by the extension system",
|
|
27
27
|
key: ext => ext.name,
|
|
28
|
+
toExtensionId: ext => `extension-module:${ext.name}`,
|
|
28
29
|
validate: ext => {
|
|
29
30
|
if (!ext.name) return "Missing name";
|
|
30
31
|
if (!ext.path) return "Missing path";
|
package/src/capability/hook.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const hookCapability = defineCapability<Hook>({
|
|
|
29
29
|
displayName: "Hooks",
|
|
30
30
|
description: "Pre/post tool execution hooks",
|
|
31
31
|
key: hook => `${hook.type}:${hook.tool}:${hook.name}`,
|
|
32
|
+
toExtensionId: hook => `hook:${hook.type}:${hook.tool}:${hook.name}`,
|
|
32
33
|
validate: hook => {
|
|
33
34
|
if (!hook.name) return "Missing name";
|
|
34
35
|
if (!hook.path) return "Missing path";
|
package/src/capability/index.ts
CHANGED
|
@@ -107,6 +107,9 @@ async function loadImpl<T>(
|
|
|
107
107
|
const allItems: Array<T & { _source: SourceMeta; _shadowed?: boolean }> = [];
|
|
108
108
|
const allWarnings: string[] = [];
|
|
109
109
|
const contributingProviders: string[] = [];
|
|
110
|
+
const disabledExtensionIds = options.includeDisabled
|
|
111
|
+
? new Set<string>()
|
|
112
|
+
: new Set<string>(options.disabledExtensions ?? settings?.get("disabledExtensions") ?? []);
|
|
110
113
|
|
|
111
114
|
const results = await Promise.all(
|
|
112
115
|
providers.map(async provider => {
|
|
@@ -136,18 +139,26 @@ async function loadImpl<T>(
|
|
|
136
139
|
allWarnings.push(...result.warnings.map(w => `[${provider.displayName}] ${w}`));
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
let contributedItemCount = 0;
|
|
143
|
+
for (const item of result.items) {
|
|
144
|
+
const itemWithSource = item as T & { _source: SourceMeta };
|
|
145
|
+
if (!itemWithSource._source) {
|
|
146
|
+
allWarnings.push(`[${provider.displayName}] Item missing _source metadata, skipping`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
141
149
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
itemWithSource._source.providerName = provider.displayName;
|
|
146
|
-
allItems.push(itemWithSource as T & { _source: SourceMeta; _shadowed?: boolean });
|
|
147
|
-
} else {
|
|
148
|
-
allWarnings.push(`[${provider.displayName}] Item missing _source metadata, skipping`);
|
|
149
|
-
}
|
|
150
|
+
const extensionId = capability.toExtensionId?.(itemWithSource);
|
|
151
|
+
if (extensionId && disabledExtensionIds.has(extensionId)) {
|
|
152
|
+
continue;
|
|
150
153
|
}
|
|
154
|
+
|
|
155
|
+
itemWithSource._source.providerName = provider.displayName;
|
|
156
|
+
allItems.push(itemWithSource as T & { _source: SourceMeta; _shadowed?: boolean });
|
|
157
|
+
contributedItemCount += 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (contributedItemCount > 0) {
|
|
161
|
+
contributingProviders.push(provider.id);
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
164
|
|
|
@@ -27,6 +27,7 @@ export const instructionCapability = defineCapability<Instruction>({
|
|
|
27
27
|
displayName: "Instructions",
|
|
28
28
|
description: "File-specific instructions with glob pattern matching (GitHub Copilot format)",
|
|
29
29
|
key: inst => inst.name,
|
|
30
|
+
toExtensionId: inst => `instruction:${inst.name}`,
|
|
30
31
|
validate: inst => {
|
|
31
32
|
if (!inst.name) return "Missing name";
|
|
32
33
|
if (!inst.path) return "Missing path";
|
package/src/capability/mcp.ts
CHANGED
|
@@ -48,6 +48,7 @@ export const mcpCapability = defineCapability<MCPServer>({
|
|
|
48
48
|
displayName: "MCP Servers",
|
|
49
49
|
description: "Model Context Protocol server configurations for external tool integrations",
|
|
50
50
|
key: server => server.name,
|
|
51
|
+
toExtensionId: server => `mcp:${server.name}`,
|
|
51
52
|
validate: server => {
|
|
52
53
|
if (!server.name) return "Missing server name";
|
|
53
54
|
if (!server.command && !server.url) return "Must have command or url";
|
package/src/capability/prompt.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const promptCapability = defineCapability<Prompt>({
|
|
|
25
25
|
displayName: "Prompts",
|
|
26
26
|
description: "Reusable prompt templates available via /prompts: menu",
|
|
27
27
|
key: prompt => prompt.name,
|
|
28
|
+
toExtensionId: prompt => `prompt:${prompt.name}`,
|
|
28
29
|
validate: prompt => {
|
|
29
30
|
if (!prompt.name) return "Missing name";
|
|
30
31
|
if (!prompt.path) return "Missing path";
|
package/src/capability/rule.ts
CHANGED
|
@@ -214,6 +214,7 @@ export const ruleCapability = defineCapability<Rule>({
|
|
|
214
214
|
displayName: "Rules",
|
|
215
215
|
description: "Project-specific rules and constraints (Cursor MDC, Windsurf, Cline formats)",
|
|
216
216
|
key: rule => rule.name,
|
|
217
|
+
toExtensionId: rule => `rule:${rule.name}`,
|
|
217
218
|
validate: rule => {
|
|
218
219
|
if (!rule.name) return "Missing rule name";
|
|
219
220
|
if (!rule.path) return "Missing rule path";
|
package/src/capability/skill.ts
CHANGED
|
@@ -40,6 +40,7 @@ export const skillCapability = defineCapability<Skill>({
|
|
|
40
40
|
displayName: "Skills",
|
|
41
41
|
description: "Specialized knowledge and workflow files that extend agent capabilities",
|
|
42
42
|
key: skill => skill.name,
|
|
43
|
+
toExtensionId: skill => `skill:${skill.name}`,
|
|
43
44
|
validate: skill => {
|
|
44
45
|
if (!skill.name) return "Missing skill name";
|
|
45
46
|
if (!skill.path) return "Missing skill path";
|
|
@@ -27,6 +27,7 @@ export const slashCommandCapability = defineCapability<SlashCommand>({
|
|
|
27
27
|
displayName: "Slash Commands",
|
|
28
28
|
description: "Custom slash commands defined as markdown files",
|
|
29
29
|
key: cmd => cmd.name,
|
|
30
|
+
toExtensionId: cmd => `slash-command:${cmd.name}`,
|
|
30
31
|
validate: cmd => {
|
|
31
32
|
if (!cmd.name) return "Missing name";
|
|
32
33
|
if (!cmd.path) return "Missing path";
|
package/src/capability/tool.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const toolCapability = defineCapability<CustomTool>({
|
|
|
29
29
|
displayName: "Custom Tools",
|
|
30
30
|
description: "User-defined tools that extend agent capabilities",
|
|
31
31
|
key: tool => tool.name,
|
|
32
|
+
toExtensionId: tool => `tool:${tool.name}`,
|
|
32
33
|
validate: tool => {
|
|
33
34
|
if (!tool.name) return "Missing name";
|
|
34
35
|
if (!tool.path) return "Missing path";
|
package/src/capability/types.ts
CHANGED
|
@@ -68,6 +68,10 @@ export interface LoadOptions {
|
|
|
68
68
|
cwd?: string;
|
|
69
69
|
/** Include items even if they fail validation. Default: false */
|
|
70
70
|
includeInvalid?: boolean;
|
|
71
|
+
/** Include items disabled via settings. Default: false */
|
|
72
|
+
includeDisabled?: boolean;
|
|
73
|
+
/** Explicit disabled extension IDs to apply instead of settings. */
|
|
74
|
+
disabledExtensions?: string[];
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
/**
|
|
@@ -123,6 +127,12 @@ export interface Capability<T> {
|
|
|
123
127
|
*/
|
|
124
128
|
validate?(item: T): string | undefined;
|
|
125
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Optional disabledExtensions ID for this item.
|
|
132
|
+
* When present, loadCapability() can hide items disabled via settings.
|
|
133
|
+
*/
|
|
134
|
+
toExtensionId?(item: T): string | undefined;
|
|
135
|
+
|
|
126
136
|
/** Registered providers, sorted by priority (highest first) */
|
|
127
137
|
providers: Provider<T>[];
|
|
128
138
|
}
|
|
@@ -5,8 +5,8 @@ import * as path from "node:path";
|
|
|
5
5
|
const APP_NAME = "omp";
|
|
6
6
|
|
|
7
7
|
export async function initXdg(): Promise<void> {
|
|
8
|
-
if (process.platform !== "linux") {
|
|
9
|
-
console.error("XDG directory setup is only supported on Linux.");
|
|
8
|
+
if (process.platform !== "linux" && process.platform !== "darwin") {
|
|
9
|
+
console.error("XDG directory setup is only supported on Linux and macOS.");
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
|
package/src/cli/config-cli.ts
CHANGED
|
@@ -397,7 +397,7 @@ ${chalk.bold("Commands:")}
|
|
|
397
397
|
set <key> <value> Set a setting value
|
|
398
398
|
reset <key> Reset a setting to its default value
|
|
399
399
|
path Print the config directory path
|
|
400
|
-
init-xdg Initialize XDG Base Directory structure
|
|
400
|
+
init-xdg Initialize XDG Base Directory structure
|
|
401
401
|
|
|
402
402
|
${chalk.bold("Options:")}
|
|
403
403
|
--json Output as JSON
|
|
@@ -226,6 +226,36 @@ export const SETTINGS_SCHEMA = {
|
|
|
226
226
|
submenu: true,
|
|
227
227
|
},
|
|
228
228
|
},
|
|
229
|
+
"tools.artifactSpillThreshold": {
|
|
230
|
+
type: "number",
|
|
231
|
+
default: 50,
|
|
232
|
+
ui: {
|
|
233
|
+
tab: "tools",
|
|
234
|
+
label: "Artifact spill threshold (KB)",
|
|
235
|
+
description: "Tool output above this size is saved as an artifact; tail is kept inline",
|
|
236
|
+
submenu: true,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
"tools.artifactTailBytes": {
|
|
240
|
+
type: "number",
|
|
241
|
+
default: 20,
|
|
242
|
+
ui: {
|
|
243
|
+
tab: "tools",
|
|
244
|
+
label: "Artifact tail size (KB)",
|
|
245
|
+
description: "Amount of tail content kept inline when output spills to artifact",
|
|
246
|
+
submenu: true,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
"tools.artifactTailLines": {
|
|
250
|
+
type: "number",
|
|
251
|
+
default: 500,
|
|
252
|
+
ui: {
|
|
253
|
+
tab: "tools",
|
|
254
|
+
label: "Artifact tail lines",
|
|
255
|
+
description: "Maximum lines of tail content kept inline when output spills to artifact",
|
|
256
|
+
submenu: true,
|
|
257
|
+
},
|
|
258
|
+
},
|
|
229
259
|
|
|
230
260
|
"statusLine.showHookStatus": {
|
|
231
261
|
type: "boolean",
|
|
@@ -629,6 +659,16 @@ export const SETTINGS_SCHEMA = {
|
|
|
629
659
|
submenu: true,
|
|
630
660
|
},
|
|
631
661
|
},
|
|
662
|
+
"compaction.thresholdTokens": {
|
|
663
|
+
type: "number",
|
|
664
|
+
default: -1,
|
|
665
|
+
ui: {
|
|
666
|
+
tab: "context",
|
|
667
|
+
label: "Compaction Token Limit",
|
|
668
|
+
description: "Fixed token limit for context maintenance; overrides percentage if set",
|
|
669
|
+
submenu: true,
|
|
670
|
+
},
|
|
671
|
+
},
|
|
632
672
|
|
|
633
673
|
"compaction.handoffSaveToDisk": {
|
|
634
674
|
type: "boolean",
|
|
@@ -1518,6 +1558,7 @@ export interface CompactionSettings {
|
|
|
1518
1558
|
enabled: boolean;
|
|
1519
1559
|
strategy: "context-full" | "handoff" | "off";
|
|
1520
1560
|
thresholdPercent: number;
|
|
1561
|
+
thresholdTokens: number;
|
|
1521
1562
|
reserveTokens: number;
|
|
1522
1563
|
keepRecentTokens: number;
|
|
1523
1564
|
handoffSaveToDisk: boolean;
|
|
@@ -1574,6 +1615,7 @@ export interface SkillsSettings {
|
|
|
1574
1615
|
customDirectories?: string[];
|
|
1575
1616
|
ignoredSkills?: string[];
|
|
1576
1617
|
includeSkills?: string[];
|
|
1618
|
+
disabledExtensions?: string[];
|
|
1577
1619
|
}
|
|
1578
1620
|
|
|
1579
1621
|
export interface CommitSettings {
|
|
@@ -18,6 +18,7 @@ import type { ExecOptions } from "../../exec/exec";
|
|
|
18
18
|
import { execCommand } from "../../exec/exec";
|
|
19
19
|
import type { CustomMessage } from "../../session/messages";
|
|
20
20
|
import { EventBus } from "../../utils/event-bus";
|
|
21
|
+
import { getAllPluginExtensionPaths } from "../plugins/loader";
|
|
21
22
|
import { resolvePath } from "../utils";
|
|
22
23
|
import type {
|
|
23
24
|
Extension,
|
|
@@ -481,7 +482,10 @@ export async function discoverAndLoadExtensions(
|
|
|
481
482
|
addPath(ext.path);
|
|
482
483
|
}
|
|
483
484
|
|
|
484
|
-
// 2.
|
|
485
|
+
// 2. Discover extension entry points from installed plugins
|
|
486
|
+
addPaths(await getAllPluginExtensionPaths(cwd));
|
|
487
|
+
|
|
488
|
+
// 3. Explicitly configured paths
|
|
485
489
|
for (const configuredPath of configuredPaths) {
|
|
486
490
|
const resolved = resolvePath(configuredPath, cwd);
|
|
487
491
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin loader - discovers and loads
|
|
2
|
+
* Plugin loader - discovers and loads manifest entry points from installed plugins.
|
|
3
3
|
*
|
|
4
|
-
* Reads enabled plugins from the runtime config and loads their
|
|
5
|
-
* based on manifest entries and enabled features.
|
|
4
|
+
* Reads enabled plugins from the runtime config and loads their
|
|
5
|
+
* tools/hooks/extensions/commands based on manifest entries and enabled features.
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
@@ -122,10 +122,10 @@ export async function getEnabledPlugins(cwd: string): Promise<InstalledPlugin[]>
|
|
|
122
122
|
// =============================================================================
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* Generic path resolver for plugin manifest entries (tools, hooks, commands).
|
|
125
|
+
* Generic path resolver for plugin manifest entries (tools, hooks, commands, extensions).
|
|
126
126
|
* Handles both single-string and string[] base entries, plus feature-specific entries.
|
|
127
127
|
*/
|
|
128
|
-
function resolvePluginPaths(plugin: InstalledPlugin, key: "tools" | "hooks" | "commands"): string[] {
|
|
128
|
+
function resolvePluginPaths(plugin: InstalledPlugin, key: "tools" | "hooks" | "commands" | "extensions"): string[] {
|
|
129
129
|
const paths: string[] = [];
|
|
130
130
|
const manifest = plugin.manifest;
|
|
131
131
|
|
|
@@ -188,6 +188,10 @@ export function resolvePluginCommandPaths(plugin: InstalledPlugin): string[] {
|
|
|
188
188
|
return resolvePluginPaths(plugin, "commands");
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
export function resolvePluginExtensionPaths(plugin: InstalledPlugin): string[] {
|
|
192
|
+
return resolvePluginPaths(plugin, "extensions");
|
|
193
|
+
}
|
|
194
|
+
|
|
191
195
|
// =============================================================================
|
|
192
196
|
// Aggregated Discovery
|
|
193
197
|
// =============================================================================
|
|
@@ -234,6 +238,20 @@ export async function getAllPluginCommandPaths(cwd: string): Promise<string[]> {
|
|
|
234
238
|
return paths;
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Get all extension module paths from all enabled plugins.
|
|
243
|
+
*/
|
|
244
|
+
export async function getAllPluginExtensionPaths(cwd: string): Promise<string[]> {
|
|
245
|
+
const plugins = await getEnabledPlugins(cwd);
|
|
246
|
+
const paths: string[] = [];
|
|
247
|
+
|
|
248
|
+
for (const plugin of plugins) {
|
|
249
|
+
paths.push(...resolvePluginExtensionPaths(plugin));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return paths;
|
|
253
|
+
}
|
|
254
|
+
|
|
237
255
|
/**
|
|
238
256
|
* Get plugin settings for use in tool/hook contexts.
|
|
239
257
|
* Merges global settings with project overrides.
|
|
@@ -585,6 +585,20 @@ export class PluginManager {
|
|
|
585
585
|
}
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
// Check extension entry paths exist if specified
|
|
589
|
+
if (manifest?.extensions) {
|
|
590
|
+
for (const extensionPath of manifest.extensions) {
|
|
591
|
+
const resolvedExtensionPath = path.join(pluginPath, extensionPath);
|
|
592
|
+
if (!fs.existsSync(resolvedExtensionPath)) {
|
|
593
|
+
checks.push({
|
|
594
|
+
name: `plugin:${name}:extension:${extensionPath}`,
|
|
595
|
+
status: "error",
|
|
596
|
+
message: `Extension entry "${extensionPath}" not found`,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
588
602
|
// Check enabled features exist in manifest
|
|
589
603
|
const runtimeState = config.plugins[name];
|
|
590
604
|
if (runtimeState?.enabledFeatures && manifest?.features) {
|
|
@@ -11,6 +11,8 @@ export interface PluginFeature {
|
|
|
11
11
|
description?: string;
|
|
12
12
|
/** Whether this feature is enabled by default */
|
|
13
13
|
default?: boolean;
|
|
14
|
+
/** Additional extension entry points provided by this feature */
|
|
15
|
+
extensions?: string[];
|
|
14
16
|
/** Additional tool entry points provided by this feature */
|
|
15
17
|
tools?: string[];
|
|
16
18
|
/** Additional hook entry points provided by this feature */
|
|
@@ -34,6 +36,8 @@ export interface PluginManifest {
|
|
|
34
36
|
tools?: string;
|
|
35
37
|
/** Entry point for base hooks (relative path from package root) */
|
|
36
38
|
hooks?: string;
|
|
39
|
+
/** Extension entry points (relative paths from package root) */
|
|
40
|
+
extensions?: string[];
|
|
37
41
|
/** Command files (relative paths from package root) */
|
|
38
42
|
commands?: string[];
|
|
39
43
|
|
|
@@ -83,6 +83,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
83
83
|
customDirectories = [],
|
|
84
84
|
ignoredSkills = [],
|
|
85
85
|
includeSkills = [],
|
|
86
|
+
disabledExtensions = [],
|
|
86
87
|
} = options;
|
|
87
88
|
|
|
88
89
|
// Early return if skills are disabled
|
|
@@ -105,7 +106,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// Use capability API to load all skills
|
|
108
|
-
const result = await loadCapability<CapabilitySkill>(skillCapability.id, { cwd });
|
|
109
|
+
const result = await loadCapability<CapabilitySkill>(skillCapability.id, { cwd, disabledExtensions });
|
|
109
110
|
|
|
110
111
|
const skillMap = new Map<string, Skill>();
|
|
111
112
|
const realPathSet = new Set<string>();
|
|
@@ -123,8 +124,12 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
123
124
|
return ignoredSkills.some(pattern => new Bun.Glob(pattern).match(name));
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
const disabledSkillNames = new Set(
|
|
128
|
+
(disabledExtensions ?? []).filter(id => id.startsWith("skill:")).map(id => id.slice(6)),
|
|
129
|
+
);
|
|
126
130
|
// Filter skills by source and patterns first
|
|
127
131
|
const filteredSkills = result.items.filter(capSkill => {
|
|
132
|
+
if (disabledSkillNames.has(capSkill.name)) return false;
|
|
128
133
|
if (!isSourceEnabled(capSkill._source)) return false;
|
|
129
134
|
if (matchesIgnorePatterns(capSkill.name)) return false;
|
|
130
135
|
if (!matchesIncludePatterns(capSkill.name)) return false;
|
|
@@ -190,6 +195,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
190
195
|
const allCustomSkills: Array<{ skill: Skill; path: string }> = [];
|
|
191
196
|
for (const { expandedDir, scanResult } of customDirectoryResults) {
|
|
192
197
|
for (const capSkill of scanResult.items) {
|
|
198
|
+
if (disabledSkillNames.has(capSkill.name)) continue;
|
|
193
199
|
if (matchesIgnorePatterns(capSkill.name)) continue;
|
|
194
200
|
if (!matchesIncludePatterns(capSkill.name)) continue;
|
|
195
201
|
allCustomSkills.push({
|
package/src/ipy/kernel.ts
CHANGED
|
@@ -274,13 +274,12 @@ export function serializeWebSocketMessage(msg: JupyterMessage): ArrayBuffer {
|
|
|
274
274
|
metadata: msg.metadata,
|
|
275
275
|
content: msg.content,
|
|
276
276
|
});
|
|
277
|
-
const msgBytes = TEXT_ENCODER.encode(msgText);
|
|
278
277
|
|
|
279
278
|
const buffers = msg.buffers ?? [];
|
|
280
279
|
const offsetCount = 1 + buffers.length;
|
|
281
280
|
const headerSize = 4 + offsetCount * 4;
|
|
282
|
-
|
|
283
|
-
let totalSize = headerSize + msgBytes
|
|
281
|
+
const msgBytes = Buffer.byteLength(msgText);
|
|
282
|
+
let totalSize = headerSize + msgBytes;
|
|
284
283
|
for (const buf of buffers) {
|
|
285
284
|
totalSize += buf.length;
|
|
286
285
|
}
|
|
@@ -293,8 +292,8 @@ export function serializeWebSocketMessage(msg: JupyterMessage): ArrayBuffer {
|
|
|
293
292
|
|
|
294
293
|
let offset = headerSize;
|
|
295
294
|
view.setUint32(4, offset, true);
|
|
296
|
-
|
|
297
|
-
offset += msgBytes
|
|
295
|
+
TEXT_ENCODER.encodeInto(msgText, bytes.subarray(offset));
|
|
296
|
+
offset += msgBytes;
|
|
298
297
|
|
|
299
298
|
for (let i = 0; i < buffers.length; i++) {
|
|
300
299
|
view.setUint32(4 + (i + 1) * 4, offset, true);
|
|
@@ -23,17 +23,12 @@ function visualizeIndent(text: string, filePath?: string): string {
|
|
|
23
23
|
const leftPadding = Math.floor(tabWidth / 2);
|
|
24
24
|
const rightPadding = Math.max(0, tabWidth - leftPadding - 1);
|
|
25
25
|
const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`;
|
|
26
|
-
// Normalize: collapse configured tab-width groups into tab markers, then handle remaining spaces.
|
|
27
|
-
const normalized = indent.replaceAll("\t", indentation);
|
|
28
26
|
let visible = "";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (pos + tabWidth <= normalized.length && normalized.slice(pos, pos + tabWidth) === indentation) {
|
|
27
|
+
for (const ch of indent) {
|
|
28
|
+
if (ch === "\t") {
|
|
32
29
|
visible += tabMarker;
|
|
33
|
-
pos += tabWidth;
|
|
34
30
|
} else {
|
|
35
31
|
visible += `${DIM}·${DIM_OFF}`;
|
|
36
|
-
pos++;
|
|
37
32
|
}
|
|
38
33
|
}
|
|
39
34
|
return `${visible}${replaceTabs(rest, filePath)}`;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* State manager for the Extension Control Center.
|
|
3
3
|
* Handles data loading, tree building, filtering, and toggle persistence.
|
|
4
4
|
*/
|
|
5
|
+
import * as path from "node:path";
|
|
5
6
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
6
7
|
import type { ContextFile } from "../../../capability/context-file";
|
|
7
8
|
import type { ExtensionModule } from "../../../capability/extension-module";
|
|
@@ -96,7 +97,7 @@ export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): P
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
const loadOpts = cwd ? { cwd } : {};
|
|
100
|
+
const loadOpts = cwd ? { cwd, includeDisabled: true } : { includeDisabled: true };
|
|
100
101
|
|
|
101
102
|
// Load skills
|
|
102
103
|
try {
|
|
@@ -252,7 +253,7 @@ export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): P
|
|
|
252
253
|
const contextFiles = await loadCapability<ContextFile>("context-files", loadOpts);
|
|
253
254
|
for (const file of contextFiles.all) {
|
|
254
255
|
// Extract filename from path for display
|
|
255
|
-
const name =
|
|
256
|
+
const name = path.basename(file.path);
|
|
256
257
|
const id = makeExtensionId("context-file", `${file.level}:${name}`);
|
|
257
258
|
const isDisabled = disabledExtensions.has(id);
|
|
258
259
|
const isShadowed = (file as { _shadowed?: boolean })._shadowed;
|
|
@@ -82,13 +82,29 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
|
|
82
82
|
// Context maintenance threshold
|
|
83
83
|
"compaction.thresholdPercent": [
|
|
84
84
|
{ value: "default", label: "Default", description: "Legacy reserve-based threshold" },
|
|
85
|
-
{ value: "
|
|
86
|
-
{ value: "
|
|
87
|
-
{ value: "
|
|
88
|
-
{ value: "
|
|
89
|
-
{ value: "
|
|
85
|
+
{ value: "10", label: "10%", description: "Extremely early maintenance" },
|
|
86
|
+
{ value: "20", label: "20%", description: "Very early maintenance" },
|
|
87
|
+
{ value: "30", label: "30%", description: "Early maintenance" },
|
|
88
|
+
{ value: "40", label: "40%", description: "Moderately early maintenance" },
|
|
89
|
+
{ value: "50", label: "50%", description: "Halfway point" },
|
|
90
|
+
{ value: "60", label: "60%", description: "Moderate context usage" },
|
|
91
|
+
{ value: "70", label: "70%", description: "Balanced" },
|
|
92
|
+
{ value: "75", label: "75%", description: "Slightly aggressive" },
|
|
93
|
+
{ value: "80", label: "80%", description: "Typical threshold" },
|
|
94
|
+
{ value: "85", label: "85%", description: "Aggressive context usage" },
|
|
95
|
+
{ value: "90", label: "90%", description: "Very aggressive" },
|
|
90
96
|
{ value: "95", label: "95%", description: "Near context limit" },
|
|
91
97
|
],
|
|
98
|
+
"compaction.thresholdTokens": [
|
|
99
|
+
{ value: "default", label: "Default", description: "Use percentage-based threshold" },
|
|
100
|
+
{ value: "25000", label: "25K tokens", description: "Quarter of a 200K window" },
|
|
101
|
+
{ value: "50000", label: "50K tokens", description: "Half of a 200K window" },
|
|
102
|
+
{ value: "100000", label: "100K tokens", description: "Half of a 200K window" },
|
|
103
|
+
{ value: "150000", label: "150K tokens", description: "Three-quarters of a 200K window" },
|
|
104
|
+
{ value: "200000", label: "200K tokens", description: "Full standard context window" },
|
|
105
|
+
{ value: "300000", label: "300K tokens", description: "Large context window" },
|
|
106
|
+
{ value: "500000", label: "500K tokens", description: "Very large context window" },
|
|
107
|
+
],
|
|
92
108
|
// Retry max retries
|
|
93
109
|
"retry.maxRetries": [
|
|
94
110
|
{ value: "1", label: "1 retry" },
|
|
@@ -190,6 +206,40 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
|
|
190
206
|
{ value: "300", label: "5 minutes" },
|
|
191
207
|
{ value: "600", label: "10 minutes" },
|
|
192
208
|
],
|
|
209
|
+
// Artifact spill settings
|
|
210
|
+
"tools.artifactSpillThreshold": [
|
|
211
|
+
{ value: "1", label: "1 KB", description: "~250 tokens" },
|
|
212
|
+
{ value: "2.5", label: "2.5 KB", description: "~625 tokens" },
|
|
213
|
+
{ value: "5", label: "5 KB", description: "~1.25K tokens" },
|
|
214
|
+
{ value: "10", label: "10 KB", description: "~2.5K tokens" },
|
|
215
|
+
{ value: "20", label: "20 KB", description: "~5K tokens" },
|
|
216
|
+
{ value: "30", label: "30 KB", description: "~7.5K tokens" },
|
|
217
|
+
{ value: "50", label: "50 KB", description: "Default; ~12.5K tokens" },
|
|
218
|
+
{ value: "75", label: "75 KB", description: "~19K tokens" },
|
|
219
|
+
{ value: "100", label: "100 KB", description: "~25K tokens" },
|
|
220
|
+
{ value: "200", label: "200 KB", description: "~50K tokens" },
|
|
221
|
+
{ value: "500", label: "500 KB", description: "~125K tokens" },
|
|
222
|
+
{ value: "1000", label: "1 MB", description: "~250K tokens" },
|
|
223
|
+
],
|
|
224
|
+
"tools.artifactTailBytes": [
|
|
225
|
+
{ value: "1", label: "1 KB", description: "~250 tokens" },
|
|
226
|
+
{ value: "2.5", label: "2.5 KB", description: "~625 tokens" },
|
|
227
|
+
{ value: "5", label: "5 KB", description: "~1.25K tokens" },
|
|
228
|
+
{ value: "10", label: "10 KB", description: "~2.5K tokens" },
|
|
229
|
+
{ value: "20", label: "20 KB", description: "Default; ~5K tokens" },
|
|
230
|
+
{ value: "50", label: "50 KB", description: "~12.5K tokens" },
|
|
231
|
+
{ value: "100", label: "100 KB", description: "~25K tokens" },
|
|
232
|
+
{ value: "200", label: "200 KB", description: "~50K tokens" },
|
|
233
|
+
],
|
|
234
|
+
"tools.artifactTailLines": [
|
|
235
|
+
{ value: "50", label: "50 lines", description: "~250 tokens" },
|
|
236
|
+
{ value: "100", label: "100 lines", description: "~500 tokens" },
|
|
237
|
+
{ value: "250", label: "250 lines", description: "~1.25K tokens" },
|
|
238
|
+
{ value: "500", label: "500 lines", description: "Default; ~2.5K tokens" },
|
|
239
|
+
{ value: "1000", label: "1000 lines", description: "~5K tokens" },
|
|
240
|
+
{ value: "2000", label: "2000 lines", description: "~10K tokens" },
|
|
241
|
+
{ value: "5000", label: "5000 lines", description: "~25K tokens" },
|
|
242
|
+
],
|
|
193
243
|
// Read line limit
|
|
194
244
|
"read.defaultLimit": [
|
|
195
245
|
{ value: "200", label: "200 lines" },
|
|
@@ -291,6 +291,9 @@ export class SettingsSelectorComponent extends Container {
|
|
|
291
291
|
if (path === "compaction.thresholdPercent" && (rawValue === "-1" || rawValue === "")) {
|
|
292
292
|
return "default";
|
|
293
293
|
}
|
|
294
|
+
if (path === "compaction.thresholdTokens" && (rawValue === "-1" || rawValue === "")) {
|
|
295
|
+
return "default";
|
|
296
|
+
}
|
|
294
297
|
return rawValue;
|
|
295
298
|
}
|
|
296
299
|
|
|
@@ -393,6 +396,8 @@ export class SettingsSelectorComponent extends Container {
|
|
|
393
396
|
const currentValue = settings.get(path);
|
|
394
397
|
if (path === "compaction.thresholdPercent" && value === "default") {
|
|
395
398
|
settings.set(path, -1 as never);
|
|
399
|
+
} else if (path === "compaction.thresholdTokens" && value === "default") {
|
|
400
|
+
settings.set(path, -1 as never);
|
|
396
401
|
} else if (typeof currentValue === "number") {
|
|
397
402
|
settings.set(path, Number(value) as never);
|
|
398
403
|
} else if (typeof currentValue === "boolean") {
|
|
@@ -27,7 +27,7 @@ import type { InteractiveModeContext } from "../../modes/types";
|
|
|
27
27
|
import type { AsyncJobSnapshotItem } from "../../session/agent-session";
|
|
28
28
|
import type { AuthStorage } from "../../session/auth-storage";
|
|
29
29
|
import { outputMeta } from "../../tools/output-meta";
|
|
30
|
-
import { resolveToCwd } from "../../tools/path-utils";
|
|
30
|
+
import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
|
|
31
31
|
import { replaceTabs } from "../../tools/render-utils";
|
|
32
32
|
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
|
|
33
33
|
import { openPath } from "../../utils/open";
|
|
@@ -679,8 +679,14 @@ export class CommandController {
|
|
|
679
679
|
return;
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
+
const unquoted = stripOuterDoubleQuotes(targetPath);
|
|
683
|
+
if (!unquoted) {
|
|
684
|
+
this.ctx.showError("Usage: /move <path>");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
682
688
|
const cwd = this.ctx.sessionManager.getCwd();
|
|
683
|
-
const resolvedPath = resolveToCwd(
|
|
689
|
+
const resolvedPath = resolveToCwd(unquoted, cwd);
|
|
684
690
|
|
|
685
691
|
try {
|
|
686
692
|
const stat = await fs.stat(resolvedPath);
|
package/src/patch/diff.ts
CHANGED
|
@@ -391,6 +391,8 @@ export async function computeHashlineDiff(
|
|
|
391
391
|
): Promise<DiffResult | DiffError> {
|
|
392
392
|
const { path, edits, move } = input;
|
|
393
393
|
const absolutePath = resolveToCwd(path, cwd);
|
|
394
|
+
const movePath = move ? resolveToCwd(move, cwd) : undefined;
|
|
395
|
+
const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
|
|
394
396
|
|
|
395
397
|
try {
|
|
396
398
|
const file = Bun.file(absolutePath);
|
|
@@ -402,6 +404,13 @@ export async function computeHashlineDiff(
|
|
|
402
404
|
return { error: `File not found: ${path}` };
|
|
403
405
|
}
|
|
404
406
|
|
|
407
|
+
if (movePath === absolutePath) {
|
|
408
|
+
return { error: "move path is the same as source path" };
|
|
409
|
+
}
|
|
410
|
+
if (isMoveOnly) {
|
|
411
|
+
return { diff: "", firstChangedLine: undefined };
|
|
412
|
+
}
|
|
413
|
+
|
|
405
414
|
let rawContent: string;
|
|
406
415
|
try {
|
|
407
416
|
rawContent = await file.text();
|
|
@@ -412,7 +421,6 @@ export async function computeHashlineDiff(
|
|
|
412
421
|
|
|
413
422
|
const { text: content } = stripBom(rawContent);
|
|
414
423
|
const normalizedContent = normalizeToLF(content);
|
|
415
|
-
|
|
416
424
|
const result = applyHashlineEdits(normalizedContent, edits);
|
|
417
425
|
if (normalizedContent === result.lines && !move) {
|
|
418
426
|
return { error: `No changes would be made to ${path}. The edits produce identical content.` };
|
package/src/patch/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* The mode is determined by the `edit.mode` setting.
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from "node:fs/promises";
|
|
12
|
+
import * as nodePath from "node:path";
|
|
12
13
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
13
14
|
import { StringEnum } from "@oh-my-pi/pi-ai";
|
|
14
15
|
import { type Static, Type } from "@sinclair/typebox";
|
|
@@ -322,11 +323,18 @@ export type EditMode = "replace" | "patch" | "hashline";
|
|
|
322
323
|
|
|
323
324
|
export const DEFAULT_EDIT_MODE: EditMode = "hashline";
|
|
324
325
|
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
);
|
|
326
|
+
const EDIT_MODES = ["replace", "patch", "hashline"] as const satisfies readonly EditMode[];
|
|
327
|
+
const EDIT_ID = Object.fromEntries(EDIT_MODES.map(mode => [mode, mode])) satisfies Record<string, EditMode>;
|
|
328
328
|
export const normalizeEditMode = (mode?: string | null): EditMode | undefined => EDIT_ID[mode ?? ""];
|
|
329
329
|
|
|
330
|
+
function isHashlineParams(params: ReplaceParams | PatchParams | HashlineParams): params is HashlineParams {
|
|
331
|
+
return "edits" in params;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isReplaceParams(params: ReplaceParams | PatchParams | HashlineParams): params is ReplaceParams {
|
|
335
|
+
return "old_text" in params && "new_text" in params;
|
|
336
|
+
}
|
|
337
|
+
|
|
330
338
|
/**
|
|
331
339
|
* Edit tool implementation.
|
|
332
340
|
*
|
|
@@ -454,7 +462,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
454
462
|
// Hashline mode execution
|
|
455
463
|
// ─────────────────────────────────────────────────────────────────
|
|
456
464
|
if (this.mode === "hashline") {
|
|
457
|
-
|
|
465
|
+
if (!isHashlineParams(params)) {
|
|
466
|
+
throw new Error("Invalid edit parameters for hashline mode.");
|
|
467
|
+
}
|
|
468
|
+
const { path, edits, delete: deleteFile, move } = params;
|
|
458
469
|
|
|
459
470
|
enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", move });
|
|
460
471
|
|
|
@@ -464,9 +475,14 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
464
475
|
|
|
465
476
|
const absolutePath = resolvePlanPath(this.session, path);
|
|
466
477
|
const resolvedMove = move ? resolvePlanPath(this.session, move) : undefined;
|
|
478
|
+
if (resolvedMove === absolutePath) {
|
|
479
|
+
throw new Error("move path is the same as source path");
|
|
480
|
+
}
|
|
481
|
+
const sourceExists = await fs.exists(absolutePath);
|
|
482
|
+
const isMoveOnly = Boolean(resolvedMove) && edits.length === 0;
|
|
467
483
|
|
|
468
484
|
if (deleteFile) {
|
|
469
|
-
if (
|
|
485
|
+
if (sourceExists) {
|
|
470
486
|
await fs.unlink(absolutePath);
|
|
471
487
|
}
|
|
472
488
|
invalidateFsScanAfterDelete(absolutePath);
|
|
@@ -480,7 +496,29 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
480
496
|
};
|
|
481
497
|
}
|
|
482
498
|
|
|
483
|
-
if (
|
|
499
|
+
if (isMoveOnly && resolvedMove) {
|
|
500
|
+
if (!sourceExists) {
|
|
501
|
+
throw new Error(`File not found: ${path}`);
|
|
502
|
+
}
|
|
503
|
+
const parentDir = nodePath.dirname(resolvedMove);
|
|
504
|
+
if (parentDir && parentDir !== ".") {
|
|
505
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
506
|
+
}
|
|
507
|
+
// Preserve exact bytes for move-only operations, including binary files.
|
|
508
|
+
await fs.rename(absolutePath, resolvedMove);
|
|
509
|
+
invalidateFsScanAfterRename(absolutePath, resolvedMove);
|
|
510
|
+
return {
|
|
511
|
+
content: [{ type: "text", text: `Moved ${path} to ${move}` }],
|
|
512
|
+
details: {
|
|
513
|
+
diff: "",
|
|
514
|
+
op: "update",
|
|
515
|
+
move,
|
|
516
|
+
meta: outputMeta().get(),
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!sourceExists) {
|
|
484
522
|
const lines: string[] = [];
|
|
485
523
|
for (const edit of edits) {
|
|
486
524
|
// For file creation, only anchorless appends/prepends are valid
|
|
@@ -628,7 +666,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
628
666
|
// Patch mode execution
|
|
629
667
|
// ─────────────────────────────────────────────────────────────────
|
|
630
668
|
if (this.mode === "patch") {
|
|
631
|
-
|
|
669
|
+
if (isHashlineParams(params) || isReplaceParams(params)) {
|
|
670
|
+
throw new Error("Invalid edit parameters for patch mode.");
|
|
671
|
+
}
|
|
672
|
+
const { path, op: rawOp, rename, diff } = params;
|
|
632
673
|
|
|
633
674
|
// Normalize unrecognized operations to "update"
|
|
634
675
|
const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
|
|
@@ -662,7 +703,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
662
703
|
const effRename = result.change.newPath ? rename : undefined;
|
|
663
704
|
|
|
664
705
|
// Generate diff for display
|
|
665
|
-
let diffResult
|
|
706
|
+
let diffResult: { diff: string; firstChangedLine: number | undefined } = {
|
|
707
|
+
diff: "",
|
|
708
|
+
firstChangedLine: undefined,
|
|
709
|
+
};
|
|
666
710
|
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
|
|
667
711
|
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
|
|
668
712
|
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
|
|
@@ -710,7 +754,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
710
754
|
// ─────────────────────────────────────────────────────────────────
|
|
711
755
|
// Replace mode execution
|
|
712
756
|
// ─────────────────────────────────────────────────────────────────
|
|
713
|
-
|
|
757
|
+
if (!isReplaceParams(params)) {
|
|
758
|
+
throw new Error("Invalid edit parameters for replace mode.");
|
|
759
|
+
}
|
|
760
|
+
const { path, old_text, new_text, all } = params;
|
|
714
761
|
|
|
715
762
|
enforcePlanModeWrite(this.session, path);
|
|
716
763
|
|
package/src/sdk.ts
CHANGED
|
@@ -627,8 +627,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
627
627
|
modelRegistry.refreshInBackground();
|
|
628
628
|
}
|
|
629
629
|
const skillsSettings = settings.getGroup("skills") as SkillsSettings;
|
|
630
|
+
const disabledExtensionIds = settings.get("disabledExtensions") ?? [];
|
|
630
631
|
const discoveredSkillsPromise =
|
|
631
|
-
options.skills === undefined
|
|
632
|
+
options.skills === undefined
|
|
633
|
+
? discoverSkills(cwd, agentDir, { ...skillsSettings, disabledExtensions: disabledExtensionIds })
|
|
634
|
+
: undefined;
|
|
632
635
|
|
|
633
636
|
// Initialize provider preferences from settings
|
|
634
637
|
const webSearchProvider = settings.get("providers.webSearch");
|
|
@@ -1006,12 +1009,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1006
1009
|
...(options.additionalExtensionPaths ?? []),
|
|
1007
1010
|
...((settings.get("extensions") as string[]) ?? []),
|
|
1008
1011
|
];
|
|
1012
|
+
const disabledExtensionIds = settings.get("disabledExtensions") ?? [];
|
|
1009
1013
|
extensionsResult = await logger.timeAsync(
|
|
1010
1014
|
"discoverAndLoadExtensions",
|
|
1011
1015
|
discoverAndLoadExtensions,
|
|
1012
1016
|
configuredPaths,
|
|
1013
1017
|
cwd,
|
|
1014
1018
|
eventBus,
|
|
1019
|
+
disabledExtensionIds,
|
|
1015
1020
|
);
|
|
1016
1021
|
for (const { path, error } of extensionsResult.errors) {
|
|
1017
1022
|
logger.error("Failed to load extension", { path, error });
|
|
@@ -136,6 +136,7 @@ export interface CompactionSettings {
|
|
|
136
136
|
enabled: boolean;
|
|
137
137
|
strategy?: "context-full" | "handoff" | "off";
|
|
138
138
|
thresholdPercent?: number;
|
|
139
|
+
thresholdTokens?: number;
|
|
139
140
|
reserveTokens: number;
|
|
140
141
|
keepRecentTokens: number;
|
|
141
142
|
autoContinue?: boolean;
|
|
@@ -147,6 +148,7 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
|
|
147
148
|
enabled: true,
|
|
148
149
|
strategy: "context-full",
|
|
149
150
|
thresholdPercent: -1,
|
|
151
|
+
thresholdTokens: -1,
|
|
150
152
|
reserveTokens: 16384,
|
|
151
153
|
keepRecentTokens: 20000,
|
|
152
154
|
autoContinue: true,
|
|
@@ -218,6 +220,14 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
|
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
|
|
223
|
+
// Fixed token limit takes priority over percentage
|
|
224
|
+
const thresholdTokens = settings.thresholdTokens;
|
|
225
|
+
if (typeof thresholdTokens === "number" && Number.isFinite(thresholdTokens) && thresholdTokens > 0) {
|
|
226
|
+
// Clamp to [1, contextWindow - 1] so there's always room
|
|
227
|
+
return Math.min(contextWindow - 1, Math.max(1, thresholdTokens));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Percentage-based threshold
|
|
221
231
|
const thresholdPercent = settings.thresholdPercent;
|
|
222
232
|
if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
|
|
223
233
|
return contextWindow - effectiveReserveTokens(contextWindow, settings);
|
|
@@ -1449,6 +1449,7 @@ export class SessionManager {
|
|
|
1449
1449
|
if (resolvedCwd === this.cwd) return;
|
|
1450
1450
|
|
|
1451
1451
|
const newSessionDir = getDefaultSessionDir(resolvedCwd, this.storage);
|
|
1452
|
+
let hadSessionFile = false;
|
|
1452
1453
|
|
|
1453
1454
|
if (this.persist && this.#sessionFile) {
|
|
1454
1455
|
// Close the persist writer before moving files
|
|
@@ -1461,12 +1462,16 @@ export class SessionManager {
|
|
|
1461
1462
|
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
|
1462
1463
|
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
1463
1464
|
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
1465
|
+
hadSessionFile = this.storage.existsSync(oldSessionFile);
|
|
1464
1466
|
let movedSessionFile = false;
|
|
1465
1467
|
let movedArtifactDir = false;
|
|
1466
1468
|
|
|
1467
1469
|
try {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
+
// Guard: session file may not exist yet (no assistant messages persisted)
|
|
1471
|
+
if (hadSessionFile) {
|
|
1472
|
+
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
1473
|
+
movedSessionFile = true;
|
|
1474
|
+
}
|
|
1470
1475
|
|
|
1471
1476
|
try {
|
|
1472
1477
|
const stat = await fs.promises.stat(oldArtifactDir);
|
|
@@ -1511,8 +1516,12 @@ export class SessionManager {
|
|
|
1511
1516
|
header.cwd = resolvedCwd;
|
|
1512
1517
|
}
|
|
1513
1518
|
|
|
1514
|
-
// Rewrite the session file at its new location with updated header
|
|
1515
|
-
|
|
1519
|
+
// Rewrite the session file at its new location with updated header.
|
|
1520
|
+
// hadSessionFile: file existed before move → must rewrite to update cwd
|
|
1521
|
+
// hasAssistant: assistant messages in memory but file missing → recreate from memory
|
|
1522
|
+
// Neither true → fresh session, never written → preserve lazy-persist
|
|
1523
|
+
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
1524
|
+
if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) {
|
|
1516
1525
|
await this.#rewriteFile();
|
|
1517
1526
|
}
|
|
1518
1527
|
|
|
@@ -1695,7 +1704,6 @@ export class SessionManager {
|
|
|
1695
1704
|
|
|
1696
1705
|
/** Flush pending writes to disk. Call before switching sessions or on shutdown. */
|
|
1697
1706
|
async flush(): Promise<void> {
|
|
1698
|
-
if (!this.#persistWriter) return;
|
|
1699
1707
|
await this.#queuePersistTask(async () => {
|
|
1700
1708
|
if (this.#persistWriter) {
|
|
1701
1709
|
await this.#persistWriter.flush();
|
package/src/tools/output-meta.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
AgentToolUpdateCallback,
|
|
13
13
|
} from "@oh-my-pi/pi-agent-core";
|
|
14
14
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
15
|
+
import { settings } from "../config/settings";
|
|
15
16
|
import { formatGroupedDiagnosticMessages } from "../lsp/utils";
|
|
16
17
|
import type { Theme } from "../modes/theme/theme";
|
|
17
18
|
import { type OutputSummary, type TruncationResult, truncateTail } from "../session/streaming-output";
|
|
@@ -438,14 +439,20 @@ const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute");
|
|
|
438
439
|
// Centralized artifact spill for large tool results
|
|
439
440
|
// =============================================================================
|
|
440
441
|
|
|
441
|
-
/**
|
|
442
|
-
|
|
442
|
+
/** Artifact spill threshold — tool output above this size is saved as an artifact. */
|
|
443
|
+
function getArtifactSpillThreshold(): number {
|
|
444
|
+
return settings.get("tools.artifactSpillThreshold") * 1024;
|
|
445
|
+
}
|
|
443
446
|
|
|
444
447
|
/** When spilling, keep this many bytes of tail in the result sent to the LLM. */
|
|
445
|
-
|
|
448
|
+
function getArtifactTailBytes(): number {
|
|
449
|
+
return settings.get("tools.artifactTailBytes") * 1024;
|
|
450
|
+
}
|
|
446
451
|
|
|
447
452
|
/** When spilling, keep at most this many lines of tail. */
|
|
448
|
-
|
|
453
|
+
function getArtifactTailLines(): number {
|
|
454
|
+
return settings.get("tools.artifactTailLines");
|
|
455
|
+
}
|
|
449
456
|
|
|
450
457
|
/**
|
|
451
458
|
* If the tool result text exceeds RESULT_ARTIFACT_THRESHOLD, save the full
|
|
@@ -476,7 +483,7 @@ async function spillLargeResultToArtifact(
|
|
|
476
483
|
|
|
477
484
|
const fullText = textParts.length === 1 ? textParts[0] : textParts.join("\n");
|
|
478
485
|
const totalBytes = Buffer.byteLength(fullText, "utf-8");
|
|
479
|
-
if (totalBytes <=
|
|
486
|
+
if (totalBytes <= getArtifactSpillThreshold()) return result;
|
|
480
487
|
|
|
481
488
|
// Save full output as artifact
|
|
482
489
|
const artifactId = await sessionManager.saveArtifact(fullText, toolName);
|
|
@@ -484,8 +491,8 @@ async function spillLargeResultToArtifact(
|
|
|
484
491
|
|
|
485
492
|
// Truncate to tail
|
|
486
493
|
const truncated = truncateTail(fullText, {
|
|
487
|
-
maxBytes:
|
|
488
|
-
maxLines:
|
|
494
|
+
maxBytes: getArtifactTailBytes(),
|
|
495
|
+
maxLines: getArtifactTailLines(),
|
|
489
496
|
});
|
|
490
497
|
|
|
491
498
|
// Replace text blocks with single tail-truncated block, keep images
|
|
@@ -508,7 +515,7 @@ async function spillLargeResultToArtifact(
|
|
|
508
515
|
totalBytes: truncated.totalBytes,
|
|
509
516
|
outputLines,
|
|
510
517
|
outputBytes,
|
|
511
|
-
maxBytes:
|
|
518
|
+
maxBytes: getArtifactTailBytes(),
|
|
512
519
|
shownRange: { start: shownStart, end: truncated.totalLines },
|
|
513
520
|
artifactId,
|
|
514
521
|
};
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -111,6 +111,17 @@ export function resolveToCwd(filePath: string, cwd: string): string {
|
|
|
111
111
|
return path.resolve(cwd, expanded);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Strip matching surrounding double quotes from a path string.
|
|
116
|
+
* Common when users paste quoted paths from Windows Explorer or shell copy-paste.
|
|
117
|
+
* Only double quotes — single quotes are valid POSIX filename characters.
|
|
118
|
+
* Tradeoff: a POSIX path literally starting AND ending with " would also be unquoted.
|
|
119
|
+
* Accepted because such names are virtually nonexistent in practice.
|
|
120
|
+
*/
|
|
121
|
+
export function stripOuterDoubleQuotes(input: string): string {
|
|
122
|
+
return input.startsWith('"') && input.endsWith('"') && input.length > 1 ? input.slice(1, -1) : input;
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
|
|
115
126
|
|
|
116
127
|
export function hasGlobPathChars(filePath: string): boolean {
|