pi-monofold 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/focus-preset.ts +41 -2
- package/focus-skills.ts +106 -0
- package/index.ts +32 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ Example command flows: [docs/examples.md](./docs/examples.md).
|
|
|
106
106
|
|
|
107
107
|
Default focus shortcut: `ctrl+shift+m` cycles Active Focus forward through `focusPresets` YAML order. No backward focus shortcut ships in the MVP.
|
|
108
108
|
|
|
109
|
-
When Active Focus is set, Pi Monofold injects the active preset's `contextFiles` into each agent turn under **Focus Context Injection** and recomposes the manifest so active Workspace Targets are shown first while non-active targets are collapsed to one-line summaries. The MVP uses provisional context-injection caps that are intentionally temporary and exposed as constants for future tuning:
|
|
109
|
+
When Active Focus is set, Pi Monofold injects the active preset's `contextFiles` into each agent turn under **Focus Context Injection** and recomposes the manifest so active Workspace Targets are shown first while non-active targets are collapsed to one-line summaries. Tag-based target inference in `monofold_read`, `monofold_write`, and `monofold_git` also prefers Workspace Targets that belong to the active preset when a tag query would otherwise match multiple candidates; explicit `targetId` / workspace name selectors and uniquely matching targets are unchanged. If multiple in-focus targets still tie, the existing workspace selection flow applies. The MVP uses provisional context-injection caps that are intentionally temporary and exposed as constants for future tuning:
|
|
110
110
|
|
|
111
111
|
- Max **6** context files per active preset.
|
|
112
112
|
- Max **6,000** characters per file, with `… [truncated]` appended when a file is cut.
|
package/focus-preset.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseFocusSkills, resetFocusSkillsWarningState } from "./focus-skills.js";
|
|
1
2
|
import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
|
|
2
3
|
|
|
3
4
|
export type FocusPresetTarget = {
|
|
@@ -8,13 +9,14 @@ export type FocusPreset = {
|
|
|
8
9
|
id: string;
|
|
9
10
|
label: string;
|
|
10
11
|
targets: FocusPresetTarget[];
|
|
12
|
+
focusSkills?: string[];
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export type FocusMatchableWorkspace = {
|
|
14
16
|
tags: string[];
|
|
15
17
|
};
|
|
16
18
|
|
|
17
|
-
const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets"]);
|
|
19
|
+
const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets", "focusSkills"]);
|
|
18
20
|
const FOCUS_PRESET_TARGET_KEYS = new Set(["targetTags"]);
|
|
19
21
|
|
|
20
22
|
/** Parses and validates focus preset configuration from YAML/JSON input. */
|
|
@@ -49,9 +51,15 @@ export function parseFocusPresets(value: unknown, label = "focusPresets"): Focus
|
|
|
49
51
|
}
|
|
50
52
|
targets.push({ targetTags });
|
|
51
53
|
}
|
|
54
|
+
const focusSkills = parseFocusSkills(itemLabel, item.focusSkills);
|
|
52
55
|
if (seenIds.has(item.id)) throw new Error(`${label} has duplicate preset id: ${item.id}`);
|
|
53
56
|
seenIds.add(item.id);
|
|
54
|
-
presets.push({
|
|
57
|
+
presets.push({
|
|
58
|
+
id: item.id,
|
|
59
|
+
label: item.label,
|
|
60
|
+
targets,
|
|
61
|
+
...(focusSkills !== undefined ? { focusSkills } : {}),
|
|
62
|
+
});
|
|
55
63
|
}
|
|
56
64
|
return presets;
|
|
57
65
|
}
|
|
@@ -118,6 +126,7 @@ export function setActiveFocusPresetId(id: string, focusPresets: FocusPreset[] |
|
|
|
118
126
|
}
|
|
119
127
|
activeFocusPresetId = id;
|
|
120
128
|
activeFocusInitialized = true;
|
|
129
|
+
resetFocusSkillsWarningState();
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
export type ActiveFocusPresetPosition = {
|
|
@@ -177,10 +186,40 @@ export function cycleActiveFocusPresetForward(focusPresets: FocusPreset[] | unde
|
|
|
177
186
|
export function clearActiveFocusPresetId(): void {
|
|
178
187
|
activeFocusPresetId = null;
|
|
179
188
|
activeFocusInitialized = true;
|
|
189
|
+
resetFocusSkillsWarningState();
|
|
180
190
|
}
|
|
181
191
|
|
|
182
192
|
/** Resets in-memory session state (for tests and process restart). */
|
|
183
193
|
export function resetActiveFocusSessionState(): void {
|
|
184
194
|
activeFocusPresetId = null;
|
|
185
195
|
activeFocusInitialized = false;
|
|
196
|
+
resetFocusSkillsWarningState();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type TagBasedTargetInput = {
|
|
200
|
+
targetTags?: string[];
|
|
201
|
+
targetId?: string;
|
|
202
|
+
targetName?: string;
|
|
203
|
+
workspaceName?: string;
|
|
204
|
+
workspaceIndex?: number;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Returns true when workspace resolution relies on tag query without an explicit selector. */
|
|
208
|
+
export function isTagBasedTargetInference(target: TagBasedTargetInput): boolean {
|
|
209
|
+
if (!target.targetTags?.length) return false;
|
|
210
|
+
if (target.targetId) return false;
|
|
211
|
+
const targetName = target.targetName ?? target.workspaceName;
|
|
212
|
+
if (targetName) return false;
|
|
213
|
+
if (target.workspaceIndex !== undefined) return false;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Narrows ambiguous tag matches to active-focus targets when at least one in-focus candidate exists. */
|
|
218
|
+
export function biasMatchesTowardActiveFocus<T extends { targetId: string }>(
|
|
219
|
+
matches: T[],
|
|
220
|
+
activeTargetIds: ReadonlySet<string>,
|
|
221
|
+
): T[] {
|
|
222
|
+
if (matches.length <= 1 || activeTargetIds.size === 0) return matches;
|
|
223
|
+
const inFocus = matches.filter((match) => activeTargetIds.has(match.targetId));
|
|
224
|
+
return inFocus.length > 0 ? inFocus : matches;
|
|
186
225
|
}
|
package/focus-skills.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { formatSkillsForPrompt, type Skill } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Max skills a single focus preset may auto-load (matches focus context file cap). */
|
|
4
|
+
export const FOCUS_SKILLS_MAX_COUNT = 6;
|
|
5
|
+
|
|
6
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const SKILLS_SECTION_PATTERN = /\n\nThe following skills provide[\s\S]*?<\/available_skills>/;
|
|
8
|
+
|
|
9
|
+
/** Validates a focusSkills entry per Agent Skills name rules. */
|
|
10
|
+
export function assertValidFocusSkillName(label: string, name: string): void {
|
|
11
|
+
if (name.trim() === "") {
|
|
12
|
+
throw new Error(`${label} must not contain empty skill names`);
|
|
13
|
+
}
|
|
14
|
+
if (name.length > 64) {
|
|
15
|
+
throw new Error(`${label} skill name exceeds 64 characters: ${name}`);
|
|
16
|
+
}
|
|
17
|
+
if (!SKILL_NAME_PATTERN.test(name)) {
|
|
18
|
+
throw new Error(`${label} skill name must use lowercase letters, digits, and hyphens only: ${name}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parses and validates an optional focusSkills list from preset config. */
|
|
23
|
+
export function parseFocusSkills(itemLabel: string, value: unknown): string[] | undefined {
|
|
24
|
+
if (value === undefined) return undefined;
|
|
25
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
26
|
+
throw new Error(`${itemLabel}.focusSkills must be an array of strings`);
|
|
27
|
+
}
|
|
28
|
+
const names: string[] = [];
|
|
29
|
+
const seen = new Set<string>();
|
|
30
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
31
|
+
const raw = value[index]!;
|
|
32
|
+
const nameLabel = `${itemLabel}.focusSkills[${index}]`;
|
|
33
|
+
assertValidFocusSkillName(nameLabel, raw);
|
|
34
|
+
if (seen.has(raw)) {
|
|
35
|
+
throw new Error(`${itemLabel}.focusSkills has duplicate skill name: ${raw}`);
|
|
36
|
+
}
|
|
37
|
+
seen.add(raw);
|
|
38
|
+
names.push(raw);
|
|
39
|
+
}
|
|
40
|
+
if (names.length > FOCUS_SKILLS_MAX_COUNT) {
|
|
41
|
+
throw new Error(`${itemLabel}.focusSkills must contain at most ${FOCUS_SKILLS_MAX_COUNT} skill names`);
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Removes Pi's default available-skills block from a system prompt, if present. */
|
|
47
|
+
export function stripSkillsSectionFromSystemPrompt(systemPrompt: string): string {
|
|
48
|
+
return systemPrompt.replace(SKILLS_SECTION_PATTERN, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolves declared focus skill names against Pi's discovered skill inventory. */
|
|
52
|
+
export function resolveFocusSkills(allSkills: Skill[] | undefined, focusSkillNames: string[] | undefined): Skill[] {
|
|
53
|
+
if (!focusSkillNames) return [];
|
|
54
|
+
const byName = new Map((allSkills ?? []).map((skill) => [skill.name, skill]));
|
|
55
|
+
return focusSkillNames.flatMap((name) => {
|
|
56
|
+
const skill = byName.get(name);
|
|
57
|
+
return skill ? [skill] : [];
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Returns focusSkills names that are not present in the discovered inventory. */
|
|
62
|
+
export function findMissingFocusSkills(focusSkillNames: string[] | undefined, allSkills: Skill[] | undefined): string[] {
|
|
63
|
+
if (!focusSkillNames?.length) return [];
|
|
64
|
+
const available = new Set((allSkills ?? []).map((skill) => skill.name));
|
|
65
|
+
return focusSkillNames.filter((name) => !available.has(name));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Replaces the default skills section with only focus-declared skills when configured. */
|
|
69
|
+
export function applyFocusSkillsToSystemPrompt(
|
|
70
|
+
systemPrompt: string,
|
|
71
|
+
allSkills: Skill[] | undefined,
|
|
72
|
+
focusSkillNames: string[] | undefined,
|
|
73
|
+
): string {
|
|
74
|
+
if (focusSkillNames === undefined) return systemPrompt;
|
|
75
|
+
const stripped = stripSkillsSectionFromSystemPrompt(systemPrompt);
|
|
76
|
+
const resolved = resolveFocusSkills(allSkills, focusSkillNames);
|
|
77
|
+
if (resolved.length === 0) return stripped;
|
|
78
|
+
return stripped + formatSkillsForPrompt(resolved);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let warnedFocusSkillsKey: string | null = null;
|
|
82
|
+
|
|
83
|
+
/** Clears per-activation focus skill warning deduplication (for tests and focus changes). */
|
|
84
|
+
export function resetFocusSkillsWarningState(): void {
|
|
85
|
+
warnedFocusSkillsKey = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Emits one actionable warning per focus activation for missing focusSkills names. */
|
|
89
|
+
export function warnMissingFocusSkills(
|
|
90
|
+
presetId: string,
|
|
91
|
+
focusSkillNames: string[] | undefined,
|
|
92
|
+
allSkills: Skill[] | undefined,
|
|
93
|
+
warn: (message: string) => void,
|
|
94
|
+
): void {
|
|
95
|
+
if (!focusSkillNames?.length) return;
|
|
96
|
+
const missing = findMissingFocusSkills(focusSkillNames, allSkills);
|
|
97
|
+
if (missing.length === 0) return;
|
|
98
|
+
const key = `${presetId}:${missing.join(",")}`;
|
|
99
|
+
if (warnedFocusSkillsKey === key) return;
|
|
100
|
+
warnedFocusSkillsKey = key;
|
|
101
|
+
for (const name of missing) {
|
|
102
|
+
warn(
|
|
103
|
+
`Focus preset "${presetId}" focusSkills: skill "${name}" was not found in Pi's discovered inventory. Check the skill name or install the package, then reload Pi.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/index.ts
CHANGED
|
@@ -12,12 +12,18 @@ import {
|
|
|
12
12
|
findFocusPresetById,
|
|
13
13
|
getActiveFocusPresetId,
|
|
14
14
|
getActiveFocusPresetPosition,
|
|
15
|
+
biasMatchesTowardActiveFocus,
|
|
16
|
+
isTagBasedTargetInference,
|
|
15
17
|
matchesFocusTarget,
|
|
16
18
|
parseFocusPresets,
|
|
17
19
|
setActiveFocusPresetByLabel,
|
|
18
20
|
setActiveFocusPresetId,
|
|
19
21
|
warnZeroTargetMatchesForPreset,
|
|
20
22
|
} from "./focus-preset.js";
|
|
23
|
+
import {
|
|
24
|
+
applyFocusSkillsToSystemPrompt,
|
|
25
|
+
warnMissingFocusSkills,
|
|
26
|
+
} from "./focus-skills.js";
|
|
21
27
|
import {
|
|
22
28
|
buildMonofoldTree,
|
|
23
29
|
readMonofoldFile,
|
|
@@ -917,9 +923,20 @@ function notifyNoFocusPresets(ctx: ExtensionContext | ExtensionCommandContext, p
|
|
|
917
923
|
if (pi) sendCommandOutput(pi, "monofold:focus", message, { focusPresets: 0 });
|
|
918
924
|
}
|
|
919
925
|
|
|
926
|
+
function biasWorkspaceMatchesWithActiveFocus(loaded: LoadedConfig, matches: ResolvedWorkspace[]): ResolvedWorkspace[] {
|
|
927
|
+
const activePreset = getActiveFocusPreset(loaded);
|
|
928
|
+
if (!activePreset) return matches;
|
|
929
|
+
const activeTargetIds = new Set(getActiveFocusWorkspaces(loaded, activePreset).map(({ workspace }) => workspace.targetId));
|
|
930
|
+
return biasMatchesTowardActiveFocus(matches, activeTargetIds);
|
|
931
|
+
}
|
|
932
|
+
|
|
920
933
|
async function resolveWorkspace(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
|
|
921
|
-
|
|
934
|
+
ensureActiveFocusInitialized(loaded.raw.focusPresets);
|
|
935
|
+
let matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
|
|
922
936
|
if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
|
|
937
|
+
if (matches.length > 1 && isTagBasedTargetInference(target)) {
|
|
938
|
+
matches = biasWorkspaceMatchesWithActiveFocus(loaded, matches);
|
|
939
|
+
}
|
|
923
940
|
if (matches.length === 1) return matches[0];
|
|
924
941
|
if (!ctx.hasUI) {
|
|
925
942
|
throw new Error(`Multiple workspaces match target in non-interactive mode: ${matches.map(formatWorkspaceLabel).join(", ")}`);
|
|
@@ -1222,9 +1239,22 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
|
|
|
1222
1239
|
"warning",
|
|
1223
1240
|
);
|
|
1224
1241
|
}
|
|
1242
|
+
if (activePreset && ctx.hasUI) {
|
|
1243
|
+
warnMissingFocusSkills(
|
|
1244
|
+
activePreset.id,
|
|
1245
|
+
activePreset.focusSkills,
|
|
1246
|
+
_event.systemPromptOptions?.skills,
|
|
1247
|
+
(message) => ctx.ui.notify(message, "warning"),
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
const baseSystemPrompt = applyFocusSkillsToSystemPrompt(
|
|
1251
|
+
_event.systemPrompt,
|
|
1252
|
+
_event.systemPromptOptions?.skills,
|
|
1253
|
+
activePreset?.focusSkills,
|
|
1254
|
+
);
|
|
1225
1255
|
return {
|
|
1226
1256
|
systemPrompt:
|
|
1227
|
-
|
|
1257
|
+
baseSystemPrompt +
|
|
1228
1258
|
`
|
|
1229
1259
|
|
|
1230
1260
|
## Pi Monofold
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
|
|
13
13
|
"type": "module",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.7.0",
|
|
15
15
|
"pi": {
|
|
16
16
|
"extensions": [
|
|
17
17
|
"./index.ts"
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"unknown-path-allows.ts",
|
|
26
26
|
"file-read-preview.ts",
|
|
27
27
|
"focus-preset.ts",
|
|
28
|
+
"focus-skills.ts",
|
|
28
29
|
"monofold-read-ops.ts",
|
|
29
30
|
"read-caps.ts",
|
|
30
31
|
"validation.ts"
|