pi-monofold 0.3.2 → 0.3.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/README.md CHANGED
@@ -20,7 +20,7 @@ See [docs/usage.md](./docs/usage.md) for configuration, commands, agent tools, a
20
20
 
21
21
  - **Virtual monorepo manifest** — declare workspaces and project workspaces in `.pi/monofold.yaml`
22
22
  - **Routed Markdown writes** — route PRDs, progress notes, and other doc types to configured folders
23
- - **Workspace-aware reads** — list, read, search, and tree views scoped to readable workspaces
23
+ - **Workspace-aware reads** — list, read, search, and tree views scoped to readable workspaces, with bounded previews by default
24
24
  - **Capability guard** — block or confirm `read` / `write` / `edit` / `grep` / `find` / `bash` based on workspace tags
25
25
  - **Focus presets** — tag-based focus targets for the control workspace
26
26
  - **Natural-language commands** — `/monofold:explore`, `/monofold:write`, `/monofold:config`, `/monofold:git`, and more
@@ -104,6 +104,29 @@ Example command flows: [docs/examples.md](./docs/examples.md).
104
104
 
105
105
  Agent tools (`monofold_list`, `monofold_read`, `monofold_write`, `monofold_git`, `monofold_init`) sit behind these commands. Full reference: [docs/usage.md](./docs/usage.md).
106
106
 
107
+ ## Safe read defaults
108
+
109
+ `monofold_read` can reach files across multiple configured workspaces. Returning full file bodies or unbounded search/tree output by default would flood the agent chat and can bias later turns. Pi Monofold therefore uses **preview-first, capped-by-default** reads.
110
+
111
+ | `monofold_read` mode | Default output |
112
+ |----------------------|----------------|
113
+ | **file** | Path, size, line/character counts, modified time, then a bounded preview (first **20** lines, up to **2,000** characters). Files that already fit those bounds are shown in full without a truncation marker. |
114
+ | **search** | Up to **50** match lines and **8,000** characters of ripgrep output. |
115
+ | **tree** | Up to **200** entries; traversal depth is capped at **5**. |
116
+
117
+ When output is cut, the tool response includes a **`[truncated]`** marker (file mode) or a **`[truncated: …]`** footer (search/tree) that states what was shown and how to request more.
118
+
119
+ **Request more content intentionally:**
120
+
121
+ | Goal | `monofold_read` parameters |
122
+ |------|----------------------------|
123
+ | Full file body | `mode: "file"`, `includeContent: true` |
124
+ | Larger bounded file slice | `head`, `tail`, and/or `maxChars` (positive integers) |
125
+ | More search results | Higher `maxMatches` and/or `maxChars`, or a narrower `path` / `query` |
126
+ | Larger directory tree | Higher `maxEntries`, lower `depth`, or a narrower `path` |
127
+
128
+ Agents should call **`monofold_read`** (not guess at raw Pi `read`). Humans should use **`/monofold:explore`** with natural language. Legacy slash commands such as `/monofold:read` apply the same caps for compatibility but are **not** the preferred human-facing surface—see [docs/usage.md](./docs/usage.md#safe-read-contract-monofold_read).
129
+
107
130
  ## Package contents
108
131
 
109
132
  ```text
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { execFile } from "node:child_process";
4
- import { access, copyFile, mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { access, copyFile, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import YAML from "yaml";
7
7
  import {
@@ -12,13 +12,17 @@ import {
12
12
  parseFocusPresets,
13
13
  warnZeroTargetMatchesForPreset,
14
14
  } from "./focus-preset.js";
15
- import { buildFileReadResponse } from "./file-read-preview.js";
16
15
  import {
17
- capSearchOutput,
18
- capTreeLines,
19
- resolveSearchCaps,
20
- resolveTreeCaps,
21
- } from "./read-caps.js";
16
+ buildMonofoldTree,
17
+ readMonofoldFile,
18
+ runMonofoldSearch,
19
+ } from "./monofold-read-ops.js";
20
+ import {
21
+ clearUnknownPathAllows,
22
+ loadUnknownPathAllows,
23
+ rememberUnknownPathAllow,
24
+ UNKNOWN_PATH_ALLOWS_RELATIVE_PATH,
25
+ } from "./unknown-path-allows.js";
22
26
  import { normalizeGuardPath } from "./path-normalize.js";
23
27
  import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
24
28
 
@@ -688,6 +692,23 @@ function stringFlag(flags: Record<string, string | boolean>, ...names: string[])
688
692
  return undefined;
689
693
  }
690
694
 
695
+ function booleanFlag(flags: Record<string, string | boolean>, ...names: string[]): boolean {
696
+ for (const name of names) {
697
+ const value = flags[name];
698
+ if (value === true || value === "true" || value === "1") return true;
699
+ }
700
+ return false;
701
+ }
702
+
703
+ function numberFlag(flags: Record<string, string | boolean>, label: string, ...names: string[]): number | undefined {
704
+ const raw = stringFlag(flags, ...names);
705
+ if (!raw) return undefined;
706
+ if (!/^[+-]?\d+$/.test(raw)) throw new Error(`${label} must be a number`);
707
+ const parsed = Number.parseInt(raw, 10);
708
+ if (!Number.isFinite(parsed)) throw new Error(`${label} must be a number`);
709
+ return parsed;
710
+ }
711
+
691
712
  function commandTarget(flags: Record<string, string | boolean>, requireCapabilities?: CapabilityTag[]): TargetInput {
692
713
  const workspace = stringFlag(flags, "target", "workspace", "w");
693
714
  const tags = stringFlag(flags, "tags", "tag")
@@ -894,39 +915,6 @@ async function buildManifest(loaded: LoadedConfig): Promise<string> {
894
915
  return lines.join("\n");
895
916
  }
896
917
 
897
- type ShallowTreeBudget = {
898
- remaining: number;
899
- truncated: boolean;
900
- };
901
-
902
- async function shallowTree(
903
- root: string,
904
- depth: number,
905
- prefix = "",
906
- budget?: ShallowTreeBudget,
907
- ): Promise<string[]> {
908
- if (depth < 0) return [];
909
- if (budget && budget.remaining <= 0) {
910
- budget.truncated = true;
911
- return [];
912
- }
913
- const entries = await readdir(path.join(root, prefix), { withFileTypes: true });
914
- const lines: string[] = [];
915
- for (const entry of entries.filter((e) => !e.name.startsWith(".git") && e.name !== "node_modules")) {
916
- if (budget && budget.remaining <= 0) {
917
- budget.truncated = true;
918
- break;
919
- }
920
- const rel = normalizeSlashes(path.join(prefix, entry.name));
921
- lines.push(entry.isDirectory() ? `${rel}/` : rel);
922
- if (budget) budget.remaining -= 1;
923
- if (entry.isDirectory() && depth > 0) {
924
- lines.push(...(await shallowTree(root, depth - 1, rel, budget)));
925
- }
926
- }
927
- return lines;
928
- }
929
-
930
918
  function slugify(title: string): string {
931
919
  const normalized = title
932
920
  .trim()
@@ -976,8 +964,16 @@ async function confirm(ctx: ExtensionContext, title: string, body: string): Prom
976
964
  async function maybeBlockUnknown(ctx: ExtensionContext, loaded: LoadedConfig, targetPath: string, action: string) {
977
965
  const workspace = findWorkspaceForPath(loaded, targetPath);
978
966
  if (workspace) return undefined;
979
- const ok = await confirm(ctx, "Unknown Path", `${action} targets an unknown path:\n${targetPath}\nAllow this operation?`);
980
- if (!ok) return { block: true, reason: `Unknown Path requires confirmation: ${targetPath}` };
967
+ const normalized = normalizeGuardPath(path.isAbsolute(targetPath) ? targetPath : path.resolve(loaded.root, targetPath));
968
+ const storedAllows = await loadUnknownPathAllows(loaded.root);
969
+ if (storedAllows.has(normalized)) return undefined;
970
+ if (!ctx.hasUI) return { block: true, reason: `Unknown Path requires confirmation: ${targetPath}` };
971
+ const choice = await ctx.ui.select(
972
+ `Unknown Path — ${normalized}`,
973
+ ["Yes (remember across sessions)", "Yes (just this once)", "No"],
974
+ );
975
+ if (!choice || choice === "No") return { block: true, reason: `Unknown Path requires confirmation: ${targetPath}` };
976
+ if (choice === "Yes (remember across sessions)") await rememberUnknownPathAllow(loaded.root, normalized);
981
977
  return undefined;
982
978
  }
983
979
 
@@ -1112,21 +1108,12 @@ ${manifest}
1112
1108
  if (params.mode === "file") {
1113
1109
  if (!params.path) throw new Error("monofold_read mode=file requires path");
1114
1110
  const filePath = relativePath(workspace, params.path);
1115
- const [content, fileStat] = await Promise.all([
1116
- readFile(filePath, "utf8"),
1117
- stat(filePath),
1118
- ]);
1119
- const preview = buildFileReadResponse(
1120
- content,
1121
- {
1122
- includeContent: params.includeContent,
1123
- maxChars: params.maxChars,
1124
- head: params.head,
1125
- tail: params.tail,
1126
- },
1127
- { size: fileStat.size, mtime: fileStat.mtime },
1128
- { relativePath: params.path },
1129
- );
1111
+ const preview = await readMonofoldFile(filePath, params.path, {
1112
+ includeContent: params.includeContent,
1113
+ maxChars: params.maxChars,
1114
+ head: params.head,
1115
+ tail: params.tail,
1116
+ });
1130
1117
  return {
1131
1118
  content: [{ type: "text", text: preview.text }],
1132
1119
  details: {
@@ -1139,10 +1126,7 @@ ${manifest}
1139
1126
  if (params.mode === "tree") {
1140
1127
  const root = params.path ? relativePath(workspace, params.path) : workspace.resolvedPath;
1141
1128
  const depth = Math.max(0, Math.min(5, params.depth ?? 1));
1142
- const treeCaps = resolveTreeCaps({ maxEntries: params.maxEntries });
1143
- const treeBudget: ShallowTreeBudget = { remaining: treeCaps.maxEntries, truncated: false };
1144
- const rawLines = await shallowTree(root, depth, "", treeBudget);
1145
- const capped = capTreeLines(rawLines, treeCaps, treeBudget.truncated);
1129
+ const capped = await buildMonofoldTree(root, depth, { maxEntries: params.maxEntries });
1146
1130
  return {
1147
1131
  content: [{ type: "text", text: capped.text }],
1148
1132
  details: {
@@ -1158,17 +1142,11 @@ ${manifest}
1158
1142
  }
1159
1143
  if (params.mode === "search") {
1160
1144
  if (!params.query) throw new Error("monofold_read mode=search requires query");
1161
- const searchCaps = resolveSearchCaps({ maxMatches: params.maxMatches, maxChars: params.maxChars });
1162
- const result = await runCommand("rg", ["--line-number", "--hidden", "--glob", "!.git/**", params.query, params.path ?? "."], {
1163
- cwd: workspace.resolvedPath,
1145
+ const capped = await runMonofoldSearch(runCommand, workspace.resolvedPath, params.query, params.path ?? ".", {
1164
1146
  signal,
1165
- timeout: 10000,
1166
- allowExitCodes: [0, 1],
1147
+ maxMatches: params.maxMatches,
1148
+ maxChars: params.maxChars,
1167
1149
  });
1168
- const rawOutput =
1169
- result.stdout.trim() ||
1170
- (result.exitCode !== 0 && result.exitCode !== 1 ? result.stderr.trim() : "");
1171
- const capped = capSearchOutput(rawOutput, searchCaps);
1172
1150
  return {
1173
1151
  content: [{ type: "text", text: capped.text }],
1174
1152
  details: {
@@ -1330,9 +1308,10 @@ ${manifest}
1330
1308
  };
1331
1309
 
1332
1310
  const readUsage = [
1333
- "/monofold:tree [path] [--workspace \"Name\"|--workspace #0] [--depth 2]",
1334
- "/monofold:read file <path> [--workspace \"Name\"|--workspace #0]",
1335
- "/monofold:search <query> [--workspace \"Name\"|--workspace #0]",
1311
+ "/monofold:read file <path> [--workspace \"Name\"|--workspace #0] [--include-content] [--max-chars N] [--head N] [--tail N]",
1312
+ "/monofold:tree [path] [--workspace \"Name\"|--workspace #0] [--depth 2] [--max-entries N]",
1313
+ "/monofold:search <query> [--workspace \"Name\"|--workspace #0] [--path subdir] [--max-matches N] [--max-chars N]",
1314
+ "Legacy read/search/tree commands return bounded previews by default. Pass --include-content or larger caps intentionally.",
1336
1315
  "Aliases: /monofold_read tree|file|search ...",
1337
1316
  ].join("\n");
1338
1317
 
@@ -1348,8 +1327,17 @@ ${manifest}
1348
1327
  const inputPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional.slice(1).join(" ");
1349
1328
  if (!inputPath) throw new Error("file mode requires path");
1350
1329
  const filePath = relativePath(workspace, inputPath);
1351
- const text = await readFile(filePath, "utf8");
1352
- sendCommandOutput(pi, `monofold:read ${formatWorkspaceLabel(workspace)}:${inputPath}`, text, { workspace, path: inputPath });
1330
+ const preview = await readMonofoldFile(filePath, inputPath, {
1331
+ includeContent: booleanFlag(parsed.flags, "include-content", "includeContent"),
1332
+ maxChars: numberFlag(parsed.flags, "max-chars", "maxChars", "max-chars"),
1333
+ head: numberFlag(parsed.flags, "head", "head"),
1334
+ tail: numberFlag(parsed.flags, "tail", "tail"),
1335
+ });
1336
+ sendCommandOutput(pi, `monofold:read ${formatWorkspaceLabel(workspace)}:${inputPath}`, preview.text, {
1337
+ workspace,
1338
+ path: inputPath,
1339
+ ...preview.details,
1340
+ });
1353
1341
  return;
1354
1342
  }
1355
1343
 
@@ -1358,13 +1346,17 @@ ${manifest}
1358
1346
  const depth = Number.parseInt(stringFlag(parsed.flags, "depth", "d") ?? "1", 10);
1359
1347
  const root = inputPath ? relativePath(workspace, inputPath) : workspace.resolvedPath;
1360
1348
  const treeDepth = Math.max(0, Math.min(5, Number.isFinite(depth) ? depth : 1));
1361
- const treeCaps = resolveTreeCaps();
1362
- const treeBudget: ShallowTreeBudget = { remaining: treeCaps.maxEntries, truncated: false };
1363
- const rawLines = await shallowTree(root, treeDepth, "", treeBudget);
1364
- const capped = capTreeLines(rawLines, treeCaps, treeBudget.truncated);
1349
+ const capped = await buildMonofoldTree(root, treeDepth, {
1350
+ maxEntries: numberFlag(parsed.flags, "max-entries", "maxEntries", "max-entries"),
1351
+ });
1365
1352
  sendCommandOutput(pi, `monofold:tree ${formatWorkspaceLabel(workspace)}:${inputPath || "."}`, capped.text, {
1366
1353
  workspace,
1367
1354
  path: inputPath || ".",
1355
+ entryCount: capped.entryCount,
1356
+ returnedEntryCount: capped.returnedEntryCount,
1357
+ maxEntries: capped.maxEntries,
1358
+ truncated: capped.truncated,
1359
+ ...(capped.hint ? { hint: capped.hint } : {}),
1368
1360
  });
1369
1361
  return;
1370
1362
  }
@@ -1372,18 +1364,20 @@ ${manifest}
1372
1364
  if (mode === "search" || mode === "grep") {
1373
1365
  const query = stringFlag(parsed.flags, "query", "q") ?? parsed.positional.slice(1).join(" ");
1374
1366
  if (!query) throw new Error("search mode requires query");
1375
- const result = await runCommand("rg", ["--line-number", "--hidden", "--glob", "!.git/**", query, "."], {
1376
- cwd: workspace.resolvedPath,
1377
- timeout: 10000,
1378
- allowExitCodes: [0, 1],
1367
+ const searchPath = stringFlag(parsed.flags, "path", "p") ?? ".";
1368
+ const capped = await runMonofoldSearch(runCommand, workspace.resolvedPath, query, searchPath, {
1369
+ maxMatches: numberFlag(parsed.flags, "max-matches", "maxMatches", "max-matches"),
1370
+ maxChars: numberFlag(parsed.flags, "max-chars", "maxChars", "max-chars"),
1379
1371
  });
1380
- const rawOutput =
1381
- result.stdout.trim() ||
1382
- (result.exitCode !== 0 && result.exitCode !== 1 ? result.stderr.trim() : "");
1383
- const capped = capSearchOutput(rawOutput, resolveSearchCaps());
1384
1372
  sendCommandOutput(pi, `monofold:search ${formatWorkspaceLabel(workspace)}:${query}`, capped.text, {
1385
1373
  workspace,
1386
1374
  query,
1375
+ matchCount: capped.matchCount,
1376
+ returnedMatchCount: capped.returnedMatchCount,
1377
+ maxMatches: capped.maxMatches,
1378
+ maxChars: capped.maxChars,
1379
+ truncated: capped.truncated,
1380
+ ...(capped.hint ? { hint: capped.hint } : {}),
1387
1381
  });
1388
1382
  return;
1389
1383
  }
@@ -1585,6 +1579,22 @@ ${manifest}
1585
1579
  }
1586
1580
  };
1587
1581
 
1582
+ const clearUnknownPathAllowsCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1583
+ try {
1584
+ const count = await clearUnknownPathAllows(ctx.cwd);
1585
+ sendCommandOutput(
1586
+ pi,
1587
+ "monofold:clear-unknown-path-allows",
1588
+ count > 0
1589
+ ? `Cleared ${count} remembered unknown path allow${count === 1 ? "" : "s"} from ${UNKNOWN_PATH_ALLOWS_RELATIVE_PATH}`
1590
+ : `No remembered unknown path allows found at ${UNKNOWN_PATH_ALLOWS_RELATIVE_PATH}`,
1591
+ { count, path: UNKNOWN_PATH_ALLOWS_RELATIVE_PATH },
1592
+ );
1593
+ } catch (error) {
1594
+ sendCommandError(pi, "monofold:clear-unknown-path-allows", error, "/monofold:clear-unknown-path-allows");
1595
+ }
1596
+ };
1597
+
1588
1598
  const intentCommand = (intent: IntentCategory) => async (args: string, ctx: ExtensionCommandContext) => {
1589
1599
  const prepared = await prepareIntentConfiguration(ctx);
1590
1600
  if (!prepared) {
@@ -1606,6 +1616,33 @@ ${manifest}
1606
1616
  pi.registerCommand("monofold:git", { description: "Run workspace git workflows via natural-language handoff", handler: intentCommand("Git") });
1607
1617
  pi.registerCommand("monofold:guide", { description: "Conversational guide for Pi Monofold workflows", handler: guideCommand });
1608
1618
  pi.registerCommand("monofold:update", { description: `Migrate and validate ${CONFIG_RELATIVE_PATH}`, handler: updateCommand });
1619
+ pi.registerCommand("monofold:clear-unknown-path-allows", {
1620
+ description: `Clear remembered unknown-path allows stored in ${UNKNOWN_PATH_ALLOWS_RELATIVE_PATH}`,
1621
+ handler: clearUnknownPathAllowsCommand,
1622
+ });
1623
+
1624
+
1625
+ pi.registerCommand("monofold:list", { description: "List configured Pi Monofold workspaces (legacy)", handler: listCommand });
1626
+
1627
+ pi.registerCommand("monofold:tree", {
1628
+ description: "Show a bounded tree for a configured workspace (legacy)",
1629
+ handler: (args, ctx) => readCommand(`tree ${args}`, ctx),
1630
+ });
1631
+
1632
+ pi.registerCommand("monofold:read", { description: "Read, tree, or search a configured workspace with safe defaults (legacy)", handler: readCommand });
1633
+
1634
+ pi.registerCommand("monofold:search", {
1635
+ description: "Search a configured workspace with safe defaults (legacy)",
1636
+ handler: (args, ctx) => readCommand(`search ${args}`, ctx),
1637
+ });
1638
+
1639
+ pi.registerCommand("monofold:add", { description: `Add a workspace to ${CONFIG_RELATIVE_PATH} (legacy)`, handler: addCommand });
1640
+
1641
+ pi.registerCommand("monofold:project-add", {
1642
+ description: "Add a project workspace under a parent workspace (legacy)",
1643
+ handler: projectAddCommand,
1644
+ });
1645
+
1609
1646
 
1610
1647
  const initCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1611
1648
  if (!ctx.hasUI) {
@@ -0,0 +1,125 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { buildFileReadResponse, type FileReadOptions } from "./file-read-preview.js";
4
+ import {
5
+ capSearchOutput,
6
+ capTreeLines,
7
+ resolveSearchCaps,
8
+ resolveTreeCaps,
9
+ type SearchCapResult,
10
+ type TreeCapResult,
11
+ } from "./read-caps.js";
12
+
13
+ export type ShallowTreeBudget = {
14
+ remaining: number;
15
+ truncated: boolean;
16
+ };
17
+
18
+ export type MonofoldFileReadResult = {
19
+ text: string;
20
+ details: ReturnType<typeof buildFileReadResponse>["details"];
21
+ };
22
+
23
+ export type RunCommandFn = (
24
+ command: string,
25
+ args: string[],
26
+ options?: {
27
+ cwd?: string;
28
+ timeout?: number;
29
+ signal?: AbortSignal;
30
+ allowExitCodes?: Array<number | string>;
31
+ },
32
+ ) => Promise<{ stdout: string; stderr: string; exitCode: number | string | null | undefined }>;
33
+
34
+ /** Maximum depth allowed for {@link buildMonofoldTree} and shallow tree walks. */
35
+ export const MAX_TREE_DEPTH = 5;
36
+
37
+ function normalizeSlashes(value: string): string {
38
+ return value.replace(/\\/g, "/");
39
+ }
40
+
41
+ export async function shallowTree(
42
+ root: string,
43
+ depth: number,
44
+ prefix = "",
45
+ budget?: ShallowTreeBudget,
46
+ ): Promise<string[]> {
47
+ if (depth < 0) return [];
48
+ if (budget && budget.remaining <= 0) {
49
+ budget.truncated = true;
50
+ return [];
51
+ }
52
+ const entries = await readdir(path.join(root, prefix), { withFileTypes: true });
53
+ const lines: string[] = [];
54
+ for (const entry of entries.filter((e) => e.name !== ".git" && e.name !== "node_modules")) {
55
+ if (budget && budget.remaining <= 0) {
56
+ budget.truncated = true;
57
+ break;
58
+ }
59
+ const rel = normalizeSlashes(path.join(prefix, entry.name));
60
+ lines.push(entry.isDirectory() ? `${rel}/` : rel);
61
+ if (budget) budget.remaining -= 1;
62
+ if (entry.isDirectory() && depth > 0) {
63
+ lines.push(...(await shallowTree(root, depth - 1, rel, budget)));
64
+ }
65
+ }
66
+ return lines;
67
+ }
68
+
69
+ export async function readMonofoldFile(
70
+ absolutePath: string,
71
+ relativePath: string,
72
+ options: FileReadOptions = {},
73
+ ): Promise<MonofoldFileReadResult> {
74
+ const [content, fileStat] = await Promise.all([readFile(absolutePath, "utf8"), stat(absolutePath)]);
75
+ const preview = buildFileReadResponse(
76
+ content,
77
+ options,
78
+ { size: fileStat.size, mtime: fileStat.mtime },
79
+ { relativePath },
80
+ );
81
+ return { text: preview.text, details: preview.details };
82
+ }
83
+
84
+ /**
85
+ * Build a bounded directory tree for a workspace root.
86
+ *
87
+ * @param root - Absolute path to the workspace root.
88
+ * @param depth - Requested traversal depth; clamped to [0, {@link MAX_TREE_DEPTH}].
89
+ * @param options - Optional caps such as `maxEntries`.
90
+ */
91
+ export async function buildMonofoldTree(
92
+ root: string,
93
+ depth: number,
94
+ options?: { maxEntries?: number },
95
+ ): Promise<TreeCapResult> {
96
+ const treeDepth = Math.max(0, Math.min(MAX_TREE_DEPTH, depth));
97
+ const treeCaps = resolveTreeCaps({ maxEntries: options?.maxEntries });
98
+ const treeBudget: ShallowTreeBudget = { remaining: treeCaps.maxEntries, truncated: false };
99
+ const rawLines = await shallowTree(root, treeDepth, "", treeBudget);
100
+ return capTreeLines(rawLines, treeCaps, treeBudget.truncated);
101
+ }
102
+
103
+ export async function runMonofoldSearch(
104
+ runCommand: RunCommandFn,
105
+ cwd: string,
106
+ query: string,
107
+ searchPath = ".",
108
+ options?: { signal?: AbortSignal; maxMatches?: number; maxChars?: number },
109
+ ): Promise<SearchCapResult & { rawOutput: string }> {
110
+ const searchCaps = resolveSearchCaps({ maxMatches: options?.maxMatches, maxChars: options?.maxChars });
111
+ const result = await runCommand(
112
+ "rg",
113
+ ["--line-number", "--hidden", "--glob", "!.git/**", "--glob", "!.github/**", query, searchPath],
114
+ {
115
+ cwd,
116
+ signal: options?.signal,
117
+ timeout: 10000,
118
+ allowExitCodes: [0, 1],
119
+ },
120
+ );
121
+ const rawOutput =
122
+ result.stdout.trim() || (result.exitCode !== 0 && result.exitCode !== 1 ? result.stderr.trim() : "");
123
+ const capped = capSearchOutput(rawOutput, searchCaps);
124
+ return { ...capped, rawOutput };
125
+ }
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.3.2",
14
+ "version": "0.3.3",
15
15
  "pi": {
16
16
  "extensions": [
17
17
  "./index.ts"
@@ -22,8 +22,10 @@
22
22
  "LICENSE",
23
23
  "index.ts",
24
24
  "path-normalize.ts",
25
+ "unknown-path-allows.ts",
25
26
  "file-read-preview.ts",
26
27
  "focus-preset.ts",
28
+ "monofold-read-ops.ts",
27
29
  "read-caps.ts",
28
30
  "validation.ts"
29
31
  ],
@@ -0,0 +1,66 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { normalizeGuardPath } from "./path-normalize.js";
4
+
5
+ export const UNKNOWN_PATH_ALLOWS_RELATIVE_PATH = path.join(".pi", "monofold-unknown-path-allows.json");
6
+
7
+ type UnknownPathAllowsFile = {
8
+ version: 1;
9
+ paths: string[];
10
+ };
11
+
12
+ function assertAllowsFile(value: unknown): asserts value is UnknownPathAllowsFile {
13
+ if (typeof value !== "object" || value === null) throw new Error("unknown path allows file must be an object");
14
+ if (!("version" in value) || (value as { version?: unknown }).version !== 1) {
15
+ throw new Error("unknown path allows file requires version: 1");
16
+ }
17
+ if (!("paths" in value) || !Array.isArray((value as { paths?: unknown }).paths) || !(value as { paths: unknown[] }).paths.every((item) => typeof item === "string")) {
18
+ throw new Error("unknown path allows file requires string[] paths");
19
+ }
20
+ }
21
+
22
+ export function resolveUnknownPathAllowsPath(root: string): string {
23
+ return path.join(normalizeGuardPath(root), UNKNOWN_PATH_ALLOWS_RELATIVE_PATH);
24
+ }
25
+
26
+ export async function loadUnknownPathAllows(root: string): Promise<Set<string>> {
27
+ const filePath = resolveUnknownPathAllowsPath(root);
28
+ try {
29
+ const text = await readFile(filePath, "utf8");
30
+ const parsed = JSON.parse(text) as unknown;
31
+ assertAllowsFile(parsed);
32
+ return new Set(parsed.paths.map((item) => normalizeGuardPath(item)));
33
+ } catch (error) {
34
+ const code = typeof error === "object" && error && "code" in error ? (error as { code?: string }).code : undefined;
35
+ if (code === "ENOENT") return new Set<string>();
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ async function saveUnknownPathAllows(root: string, paths: Set<string>): Promise<void> {
41
+ const filePath = resolveUnknownPathAllowsPath(root);
42
+ await mkdir(path.dirname(filePath), { recursive: true });
43
+ const payload: UnknownPathAllowsFile = {
44
+ version: 1,
45
+ paths: [...paths].map((item) => normalizeGuardPath(item)).sort(),
46
+ };
47
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
48
+ }
49
+
50
+ export async function rememberUnknownPathAllow(root: string, targetPath: string): Promise<void> {
51
+ const paths = await loadUnknownPathAllows(root);
52
+ paths.add(normalizeGuardPath(targetPath));
53
+ await saveUnknownPathAllows(root, paths);
54
+ }
55
+
56
+ export async function clearUnknownPathAllows(root: string): Promise<number> {
57
+ const filePath = resolveUnknownPathAllowsPath(root);
58
+ const paths = await loadUnknownPathAllows(root);
59
+ try {
60
+ await rm(filePath, { force: true });
61
+ } catch (error) {
62
+ const code = typeof error === "object" && error && "code" in error ? (error as { code?: string }).code : undefined;
63
+ if (code !== "ENOENT") throw error;
64
+ }
65
+ return paths.size;
66
+ }