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 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({ id: item.id, label: item.label, targets });
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
  }
@@ -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
- const matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
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
- _event.systemPrompt +
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.6.1",
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"