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 +24 -1
- package/index.ts +124 -87
- package/monofold-read-ops.ts +125 -0
- package/package.json +3 -1
- package/unknown-path-allows.ts +66 -0
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,
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
980
|
-
|
|
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
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1166
|
-
|
|
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:
|
|
1334
|
-
"/monofold:
|
|
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
|
|
1352
|
-
|
|
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
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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.
|
|
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
|
+
}
|