pi-monofold 0.4.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/index.ts +154 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -106,6 +106,12 @@ 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:
110
+
111
+ - Max **6** context files per active preset.
112
+ - Max **6,000** characters per file, with `… [truncated]` appended when a file is cut.
113
+ - Max **12,000** injected file-content characters per turn; remaining files are skipped and a warning is surfaced once for that turn.
114
+
109
115
  Agent tools (`monofold_list`, `monofold_read`, `monofold_write`, `monofold_git`, `monofold_init`) sit behind these commands. Full reference: [docs/usage.md](./docs/usage.md).
110
116
 
111
117
  ## Safe read defaults
package/index.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  findFocusPresetById,
13
13
  getActiveFocusPresetId,
14
14
  getActiveFocusPresetPosition,
15
+ matchesFocusTarget,
15
16
  parseFocusPresets,
16
17
  setActiveFocusPresetByLabel,
17
18
  setActiveFocusPresetId,
@@ -170,6 +171,20 @@ const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
170
171
  const FOCUS_STATUS_ID = "monofold-focus";
171
172
  const FOCUS_CYCLE_SHORTCUT = "ctrl+shift+m";
172
173
  const FOCUS_CYCLE_ACTION_ID = "app.monofold.focus.cycleForward";
174
+ export const FOCUS_CONTEXT_MAX_FILES = 6;
175
+ export const FOCUS_CONTEXT_MAX_CHARS_PER_FILE = 6_000;
176
+ export const FOCUS_CONTEXT_MAX_TOTAL_CHARS = 12_000;
177
+ const FOCUS_CONTEXT_TRUNCATION_MARKER = "… [truncated]";
178
+
179
+ type ActiveFocusWorkspace = {
180
+ workspace: ResolvedWorkspace;
181
+ targetIndex: number;
182
+ };
183
+
184
+ type FocusContextInjection = {
185
+ text: string;
186
+ totalCapReached: boolean;
187
+ };
173
188
 
174
189
  function normalizeSlashes(value: string): string {
175
190
  return value.replace(/\\/g, "/");
@@ -921,6 +936,42 @@ function formatWorkspaceLabel(workspace: ResolvedWorkspace): string {
921
936
  return `${workspace.targetId} ${displayName}[${workspace.tags.join(", ")}] ${workspace.displayPath}${parent}`;
922
937
  }
923
938
 
939
+ function formatCollapsedWorkspaceLine(workspace: ResolvedWorkspace): string {
940
+ const label = workspace.name ?? "(unnamed)";
941
+ return `${workspace.targetId} ${label} [${workspace.tags.join(", ")}] ${workspace.displayPath}`;
942
+ }
943
+
944
+ function getActiveFocusPreset(loaded: LoadedConfig): FocusPreset | undefined {
945
+ const activeId = getActiveFocusPresetId();
946
+ return activeId ? findFocusPresetById(loaded.raw.focusPresets, activeId) : undefined;
947
+ }
948
+
949
+ function getActiveFocusWorkspaces(loaded: LoadedConfig, preset: FocusPreset): ActiveFocusWorkspace[] {
950
+ const active: ActiveFocusWorkspace[] = [];
951
+ const seenTargets = new Set<string>();
952
+ for (let targetIndex = 0; targetIndex < preset.targets.length; targetIndex += 1) {
953
+ const target = preset.targets[targetIndex]!;
954
+ for (const workspace of loaded.workspaces) {
955
+ if (seenTargets.has(workspace.targetId)) continue;
956
+ if (!matchesFocusTarget(workspace, target.targetTags)) continue;
957
+ seenTargets.add(workspace.targetId);
958
+ active.push({ workspace, targetIndex });
959
+ }
960
+ }
961
+ return active;
962
+ }
963
+
964
+ function truncateWithMarker(value: string, maxChars: number): { text: string; truncated: boolean } {
965
+ if (value.length <= maxChars) return { text: value, truncated: false };
966
+ if (maxChars <= FOCUS_CONTEXT_TRUNCATION_MARKER.length) {
967
+ return { text: FOCUS_CONTEXT_TRUNCATION_MARKER.slice(0, maxChars), truncated: true };
968
+ }
969
+ return {
970
+ text: value.slice(0, maxChars - FOCUS_CONTEXT_TRUNCATION_MARKER.length) + FOCUS_CONTEXT_TRUNCATION_MARKER,
971
+ truncated: true,
972
+ };
973
+ }
974
+
924
975
  function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
925
976
  assertWorkspaceInternalRelative("path", inputPath);
926
977
  return path.join(workspace.resolvedPath, inputPath);
@@ -939,22 +990,104 @@ async function gitRoot(workspace: ResolvedWorkspace): Promise<string | undefined
939
990
  return undefined;
940
991
  }
941
992
 
993
+ async function buildFullWorkspaceManifestEntry(workspace: ResolvedWorkspace, suffix = ""): Promise<string> {
994
+ const git = await gitSummary(workspace).catch((error) => ({ isGit: false, status: `git status error: ${String(error)}` }));
995
+ return (
996
+ `- ${formatWorkspaceLabel(workspace)}${suffix}\n` +
997
+ ` capabilities: ${workspace.capabilities.join(", ")}\n` +
998
+ ` routes: ${Object.keys(workspace.normalizedRoutes).join(", ") || "none"}\n` +
999
+ ` contextFiles: ${workspace.effectiveContextFiles.join(", ") || "none"}\n` +
1000
+ ` git: ${git.isGit ? git.status : "not a git repository"}`
1001
+ );
1002
+ }
1003
+
942
1004
  async function buildManifest(loaded: LoadedConfig): Promise<string> {
943
1005
  const lines = ["Pi Monofold Manifest:"];
944
- for (const workspace of loaded.workspaces) {
945
- const git = await gitSummary(workspace).catch((error) => ({ isGit: false, status: `git status error: ${String(error)}` }));
946
- lines.push(
947
- `- ${formatWorkspaceLabel(workspace)}\n` +
948
- ` capabilities: ${workspace.capabilities.join(", ")}\n` +
949
- ` routes: ${Object.keys(workspace.normalizedRoutes).join(", ") || "none"}\n` +
950
- ` contextFiles: ${workspace.effectiveContextFiles.join(", ") || "none"}\n` +
951
- ` git: ${git.isGit ? git.status : "not a git repository"}`,
952
- );
1006
+ const activePreset = getActiveFocusPreset(loaded);
1007
+ if (activePreset) {
1008
+ const activeWorkspaces = getActiveFocusWorkspaces(loaded, activePreset);
1009
+ const activeTargetIds = new Set(activeWorkspaces.map(({ workspace }) => workspace.targetId));
1010
+ if (activeWorkspaces.length > 0) {
1011
+ lines.push(`Active Focus: ${activePreset.label} (${activePreset.id})`);
1012
+ for (const { workspace } of activeWorkspaces) {
1013
+ lines.push(await buildFullWorkspaceManifestEntry(workspace, " (active)"));
1014
+ }
1015
+ const collapsed = loaded.workspaces.filter((workspace) => !activeTargetIds.has(workspace.targetId));
1016
+ if (collapsed.length > 0) {
1017
+ lines.push("Non-active Workspace Targets (collapsed):");
1018
+ for (const workspace of collapsed) lines.push(`- ${formatCollapsedWorkspaceLine(workspace)}`);
1019
+ }
1020
+ lines.push("Use monofold_* tools for cross-workspace operations. Do not guess output paths when a route exists.");
1021
+ return lines.join("\n");
1022
+ }
953
1023
  }
1024
+ for (const workspace of loaded.workspaces) lines.push(await buildFullWorkspaceManifestEntry(workspace));
954
1025
  lines.push("Use monofold_* tools for cross-workspace operations. Do not guess output paths when a route exists.");
955
1026
  return lines.join("\n");
956
1027
  }
957
1028
 
1029
+ async function buildFocusContextInjection(loaded: LoadedConfig, preset: FocusPreset): Promise<FocusContextInjection> {
1030
+ const activeWorkspaces = getActiveFocusWorkspaces(loaded, preset);
1031
+ const lines = ["## Focus Context Injection", "", `Active Focus: ${preset.label} (${preset.id})`];
1032
+ const notices: string[] = [];
1033
+ const seenFiles = new Set<string>();
1034
+ let injectedFileCount = 0;
1035
+ let totalInjectedChars = 0;
1036
+ let skippedByFileCap = 0;
1037
+ let skippedByTotalCap = 0;
1038
+ let totalCapReached = false;
1039
+
1040
+ for (const { workspace } of activeWorkspaces) {
1041
+ for (const contextFile of workspace.effectiveContextFiles) {
1042
+ const absolutePath = relativePath(workspace, contextFile);
1043
+ const dedupeKey = normalizeGuardPath(absolutePath);
1044
+ if (seenFiles.has(dedupeKey)) continue;
1045
+ seenFiles.add(dedupeKey);
1046
+
1047
+ if (injectedFileCount >= FOCUS_CONTEXT_MAX_FILES) {
1048
+ skippedByFileCap += 1;
1049
+ continue;
1050
+ }
1051
+
1052
+ let raw: string;
1053
+ try {
1054
+ raw = await readFile(absolutePath, "utf8");
1055
+ } catch (error) {
1056
+ const message = error instanceof Error ? error.message : String(error);
1057
+ notices.push(`Skipped unreadable context file ${workspace.targetId}:${contextFile} (${message})`);
1058
+ continue;
1059
+ }
1060
+
1061
+ const perFile = truncateWithMarker(raw, FOCUS_CONTEXT_MAX_CHARS_PER_FILE);
1062
+ if (perFile.truncated) notices.push(`Truncated ${workspace.targetId}:${contextFile} to ${FOCUS_CONTEXT_MAX_CHARS_PER_FILE} characters`);
1063
+
1064
+ if (totalInjectedChars + perFile.text.length > FOCUS_CONTEXT_MAX_TOTAL_CHARS) {
1065
+ skippedByTotalCap += 1;
1066
+ totalCapReached = true;
1067
+ continue;
1068
+ }
1069
+
1070
+ injectedFileCount += 1;
1071
+ totalInjectedChars += perFile.text.length;
1072
+ lines.push(
1073
+ "",
1074
+ `### ${workspace.targetId} ${workspace.name ?? "(unnamed)"}: ${contextFile}`,
1075
+ `Workspace Target: ${formatWorkspaceLabel(workspace)}`,
1076
+ "```text",
1077
+ perFile.text,
1078
+ "```",
1079
+ );
1080
+ }
1081
+ }
1082
+
1083
+ if (skippedByFileCap > 0) notices.push(`Skipped ${skippedByFileCap} context file(s) after the ${FOCUS_CONTEXT_MAX_FILES}-file cap`);
1084
+ if (skippedByTotalCap > 0) notices.push(`Skipped ${skippedByTotalCap} context file(s) after the ${FOCUS_CONTEXT_MAX_TOTAL_CHARS}-character total cap`);
1085
+ if (injectedFileCount === 0) notices.push("No focus context files were injected for the active preset");
1086
+ if (notices.length > 0) lines.splice(3, 0, "", "Notices:", ...notices.map((notice) => `- ${notice}`));
1087
+
1088
+ return { text: lines.join("\n"), totalCapReached };
1089
+ }
1090
+
958
1091
  function slugify(title: string): string {
959
1092
  const normalized = title
960
1093
  .trim()
@@ -1079,7 +1212,16 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
1079
1212
  pi.on("before_agent_start", async (_event, ctx) => {
1080
1213
  try {
1081
1214
  const loaded = await loadConfig(ctx.cwd);
1215
+ ensureActiveFocusInitialized(loaded.raw.focusPresets);
1082
1216
  const manifest = await buildManifest(loaded);
1217
+ const activePreset = getActiveFocusPreset(loaded);
1218
+ const focusInjection = activePreset ? await buildFocusContextInjection(loaded, activePreset) : undefined;
1219
+ if (focusInjection?.totalCapReached && ctx.hasUI) {
1220
+ ctx.ui.notify(
1221
+ `Focus Context Injection reached the ${FOCUS_CONTEXT_MAX_TOTAL_CHARS}-character total cap; skipped remaining context files.`,
1222
+ "warning",
1223
+ );
1224
+ }
1083
1225
  return {
1084
1226
  systemPrompt:
1085
1227
  _event.systemPrompt +
@@ -1088,6 +1230,9 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
1088
1230
  ## Pi Monofold
1089
1231
 
1090
1232
  ${manifest}
1233
+ ${focusInjection ? `
1234
+
1235
+ ${focusInjection.text}` : ""}
1091
1236
  `,
1092
1237
  };
1093
1238
  } catch {
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.4.0",
14
+ "version": "0.6.0",
15
15
  "pi": {
16
16
  "extensions": [
17
17
  "./index.ts"