@oh-my-pi/pi-coding-agent 14.9.1 → 14.9.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/CHANGELOG.md +60 -0
- package/package.json +7 -7
- package/scripts/format-prompts.ts +3 -3
- package/src/config/prompt-templates.ts +0 -5
- package/src/config/settings-schema.ts +38 -0
- package/src/eval/eval.lark +10 -31
- package/src/eval/index.ts +1 -0
- package/src/eval/parse.ts +156 -255
- package/src/eval/sniff.ts +28 -0
- package/src/export/html/template.css +38 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +209 -15
- package/src/extensibility/extensions/runner.ts +173 -177
- package/src/hashline/apply.ts +8 -24
- package/src/hashline/constants.ts +20 -0
- package/src/hashline/execute.ts +0 -1
- package/src/hashline/grammar.lark +16 -27
- package/src/hashline/hash.ts +4 -34
- package/src/hashline/input.ts +16 -2
- package/src/hashline/parser.ts +12 -40
- package/src/hashline/types.ts +1 -2
- package/src/internal-urls/agent-protocol.ts +1 -0
- package/src/internal-urls/artifact-protocol.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +2 -1
- package/src/internal-urls/jobs-protocol.ts +1 -0
- package/src/internal-urls/local-protocol.ts +1 -0
- package/src/internal-urls/mcp-protocol.ts +1 -0
- package/src/internal-urls/memory-protocol.ts +1 -0
- package/src/internal-urls/pi-protocol.ts +1 -0
- package/src/internal-urls/router.ts +2 -1
- package/src/internal-urls/rule-protocol.ts +1 -0
- package/src/internal-urls/skill-protocol.ts +1 -0
- package/src/internal-urls/types.ts +18 -2
- package/src/mcp/transports/http.ts +49 -47
- package/src/prompts/system/custom-system-prompt.md +0 -2
- package/src/prompts/system/now-prompt.md +7 -0
- package/src/prompts/system/project-prompt.md +2 -0
- package/src/prompts/system/subagent-system-prompt.md +18 -9
- package/src/prompts/system/subagent-user-prompt.md +1 -10
- package/src/prompts/system/system-prompt.md +154 -233
- package/src/prompts/tools/bash.md +0 -24
- package/src/prompts/tools/eval.md +26 -13
- package/src/prompts/tools/hashline.md +1 -4
- package/src/sdk.ts +12 -22
- package/src/session/agent-session.ts +49 -17
- package/src/system-prompt.ts +38 -104
- package/src/task/executor.ts +15 -9
- package/src/task/index.ts +38 -33
- package/src/task/render.ts +4 -2
- package/src/tools/bash.ts +15 -41
- package/src/tools/eval.ts +13 -36
- package/src/tools/index.ts +0 -3
- package/src/tools/path-utils.ts +21 -1
- package/src/tools/read.ts +71 -49
- package/src/tools/search.ts +13 -1
- package/src/utils/file-display-mode.ts +11 -5
- package/src/workspace-tree.ts +210 -410
- package/src/task/template.ts +0 -47
- package/src/tools/bash-normalize.ts +0 -107
package/src/workspace-tree.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import * as fs from "node:fs/promises";
|
|
2
1
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { FileType, type GlobMatch, listWorkspace } from "@oh-my-pi/pi-natives";
|
|
3
|
+
import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
|
|
4
|
+
|
|
5
|
+
/** Defaults for the workspace tree shown in the system prompt. */
|
|
6
|
+
const WORKSPACE_DEFAULTS = {
|
|
7
|
+
maxDepth: 3,
|
|
8
|
+
perDirLimit: 12,
|
|
9
|
+
lineCap: 120,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hard cap on AGENTS.md files surfaced by `buildWorkspaceTree`. Mirrors the
|
|
14
|
+
* native cap so the system-prompt builder does not need a second pass.
|
|
15
|
+
*/
|
|
16
|
+
export const AGENTS_MD_LIMIT = 200;
|
|
5
17
|
|
|
6
18
|
export interface DirectoryTree {
|
|
7
19
|
rootPath: string;
|
|
@@ -10,360 +22,250 @@ export interface DirectoryTree {
|
|
|
10
22
|
totalLines: number;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
|
-
export interface WorkspaceTree extends DirectoryTree {
|
|
25
|
+
export interface WorkspaceTree extends DirectoryTree {
|
|
26
|
+
/** AGENTS.md files beneath the root whose rules may apply to subdirectories. */
|
|
27
|
+
agentsMdFiles: string[];
|
|
28
|
+
}
|
|
14
29
|
|
|
15
|
-
export interface
|
|
16
|
-
/** Directory depth below the root to include. Root itself is depth 0. */
|
|
30
|
+
export interface BuildDirectoryTreeOptions {
|
|
31
|
+
/** Directory depth below the root to include. Root itself is depth 0. Default: 1. */
|
|
17
32
|
maxDepth?: number;
|
|
18
|
-
/** Per-directory child cap.
|
|
19
|
-
|
|
20
|
-
/** Optional root
|
|
21
|
-
|
|
22
|
-
/** Hard rendered line cap.
|
|
33
|
+
/** Per-directory child cap. `null` disables the cap. Default: `null`. */
|
|
34
|
+
perDirLimit?: number | null;
|
|
35
|
+
/** Optional override for the root level. Defaults to `perDirLimit`. */
|
|
36
|
+
rootLimit?: number | null;
|
|
37
|
+
/** Hard rendered line cap. `null` disables. Default: `null`. */
|
|
23
38
|
lineCap?: number | null;
|
|
24
|
-
/** Depth at or above which line-cap pruning is forbidden. Root is 0, root children are 1. */
|
|
25
|
-
lineCapProtectedDepth?: number;
|
|
26
|
-
/** Entry names to skip before stat/render. */
|
|
27
|
-
excludedNames?: ReadonlySet<string> | readonly string[];
|
|
28
|
-
/** Directory names to skip before traversal. */
|
|
29
|
-
excludedDirectoryNames?: ReadonlySet<string> | readonly string[];
|
|
30
|
-
/** Include hidden files and directories. */
|
|
31
|
-
hidden?: boolean;
|
|
32
|
-
/** Respect .gitignore while listing children. */
|
|
33
|
-
gitignore?: boolean;
|
|
34
|
-
/** Use native glob shared cache. */
|
|
35
|
-
cache?: boolean;
|
|
36
|
-
/** Rendered label for the root line. */
|
|
37
|
-
rootLabel?: string;
|
|
38
|
-
/**
|
|
39
|
-
* Pre-built map of `parentRelativePath` → child name set used in place of
|
|
40
|
-
* native directory listing. When provided, the tree builder consults this
|
|
41
|
-
* map for child enumeration instead of `glob` / `readdir`. Stat calls per
|
|
42
|
-
* displayed node are still performed for mtime/size/dir-ness.
|
|
43
|
-
*/
|
|
44
|
-
childIndex?: ReadonlyMap<string, ReadonlySet<string>>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const WORKSPACE_TREE_MAX_DEPTH = 3;
|
|
48
|
-
const WORKSPACE_TREE_DIR_LIMIT = 12;
|
|
49
|
-
const WORKSPACE_TREE_LINE_CAP = 120;
|
|
50
|
-
const WORKSPACE_TREE_EXCLUDED_DIRS = new Set([
|
|
51
|
-
"node_modules",
|
|
52
|
-
".git",
|
|
53
|
-
".next",
|
|
54
|
-
"dist",
|
|
55
|
-
"build",
|
|
56
|
-
"target",
|
|
57
|
-
".venv",
|
|
58
|
-
".cache",
|
|
59
|
-
".turbo",
|
|
60
|
-
".parcel-cache",
|
|
61
|
-
"coverage",
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
|
-
const DIRECTORY_TREE_EXCLUDED_NAMES = new Set([".DS_Store"]);
|
|
65
|
-
|
|
66
|
-
const GLOB_SPECIAL_CHARS = new Set(["!", "(", ")", "*", "?", "[", "]", "{", "}", "\\"]);
|
|
67
|
-
|
|
68
|
-
interface DirectoryTreeNode {
|
|
69
|
-
name: string;
|
|
70
|
-
relativePath: string;
|
|
71
|
-
depth: number;
|
|
72
|
-
isDirectory: boolean;
|
|
73
|
-
mtimeMs: number;
|
|
74
|
-
size: number;
|
|
75
|
-
children: DirectoryTreeNode[];
|
|
76
|
-
droppedChildCount: number;
|
|
77
39
|
}
|
|
78
40
|
|
|
79
|
-
interface
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
rootEntryLimit: number | null;
|
|
83
|
-
lineCap: number | null;
|
|
84
|
-
lineCapProtectedDepth: number;
|
|
85
|
-
excludedDirectoryNames: ReadonlySet<string>;
|
|
86
|
-
excludedNames: ReadonlySet<string>;
|
|
87
|
-
hidden: boolean;
|
|
88
|
-
gitignore: boolean;
|
|
89
|
-
cache: boolean;
|
|
90
|
-
rootLabel: string;
|
|
91
|
-
childIndex: ReadonlyMap<string, ReadonlySet<string>> | null;
|
|
41
|
+
export interface BuildWorkspaceTreeOptions {
|
|
42
|
+
/** Abort the native workspace scan after this many milliseconds. */
|
|
43
|
+
timeoutMs?: number;
|
|
92
44
|
}
|
|
93
45
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
46
|
+
/**
|
|
47
|
+
* Build a generic directory tree using a single native scan. Hidden files are
|
|
48
|
+
* shown, .gitignore is not consulted, and the standard non-source directories
|
|
49
|
+
* (`node_modules`, `.git`, build outputs, caches…) are pruned by the native
|
|
50
|
+
* walker. Used by the read tool's directory-listing path.
|
|
51
|
+
*/
|
|
52
|
+
export async function buildDirectoryTree(cwd: string, options: BuildDirectoryTreeOptions = {}): Promise<DirectoryTree> {
|
|
53
|
+
const rootPath = path.resolve(cwd);
|
|
54
|
+
const maxDepth = options.maxDepth ?? 1;
|
|
55
|
+
const perDirLimit = options.perDirLimit === undefined ? null : options.perDirLimit;
|
|
56
|
+
const rootLimit = options.rootLimit === undefined ? perDirLimit : options.rootLimit;
|
|
101
57
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
58
|
+
let entries: readonly GlobMatch[];
|
|
59
|
+
let nativeTruncated: boolean;
|
|
60
|
+
try {
|
|
61
|
+
const result = await listWorkspace({
|
|
62
|
+
path: rootPath,
|
|
63
|
+
maxDepth,
|
|
64
|
+
hidden: true,
|
|
65
|
+
gitignore: false,
|
|
66
|
+
});
|
|
67
|
+
entries = result.entries;
|
|
68
|
+
nativeTruncated = result.truncated;
|
|
69
|
+
} catch {
|
|
70
|
+
return emptyTree(rootPath);
|
|
71
|
+
}
|
|
110
72
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const excludedDirectoryNames =
|
|
115
|
-
options.excludedDirectoryNames instanceof Set
|
|
116
|
-
? options.excludedDirectoryNames
|
|
117
|
-
: new Set(options.excludedDirectoryNames ?? []);
|
|
118
|
-
const providedExcludedNames =
|
|
119
|
-
options.excludedNames instanceof Set ? options.excludedNames : new Set(options.excludedNames ?? []);
|
|
120
|
-
const excludedNames = new Set([...DIRECTORY_TREE_EXCLUDED_NAMES, ...providedExcludedNames]);
|
|
121
|
-
return {
|
|
122
|
-
maxDepth: options.maxDepth ?? 1,
|
|
123
|
-
directoryEntryLimit,
|
|
124
|
-
rootEntryLimit,
|
|
73
|
+
return assembleTree(rootPath, entries, {
|
|
74
|
+
perDirLimit,
|
|
75
|
+
rootLimit,
|
|
125
76
|
lineCap: options.lineCap === undefined ? null : options.lineCap,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
excludedNames,
|
|
129
|
-
hidden: options.hidden ?? true,
|
|
130
|
-
gitignore: options.gitignore ?? false,
|
|
131
|
-
cache: options.cache ?? true,
|
|
132
|
-
rootLabel: options.rootLabel ?? ".",
|
|
133
|
-
childIndex: options.childIndex ?? null,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function compareByRecency(a: DirectoryTreeNode, b: DirectoryTreeNode): number {
|
|
138
|
-
const mtimeCompare = b.mtimeMs - a.mtimeMs;
|
|
139
|
-
if (mtimeCompare !== 0) return mtimeCompare;
|
|
140
|
-
return a.name.localeCompare(b.name);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function childRelativePath(parentRelativePath: string, name: string): string {
|
|
144
|
-
return parentRelativePath ? `${parentRelativePath}/${name}` : name;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function escapeGlobSegment(segment: string): string {
|
|
148
|
-
return Array.from(segment, char => (GLOB_SPECIAL_CHARS.has(char) ? `\\${char}` : char)).join("");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function directChildPattern(parentRelativePath: string): string {
|
|
152
|
-
if (!parentRelativePath) return "*";
|
|
153
|
-
return `${parentRelativePath.split("/").map(escapeGlobSegment).join("/")}/*`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function matchChildName(parentRelativePath: string, matchPath: string): string | null {
|
|
157
|
-
if (!parentRelativePath) return matchPath.includes("/") ? null : matchPath;
|
|
158
|
-
const prefix = `${parentRelativePath}/`;
|
|
159
|
-
if (!matchPath.startsWith(prefix)) return null;
|
|
160
|
-
const name = matchPath.slice(prefix.length);
|
|
161
|
-
return name.includes("/") ? null : name;
|
|
77
|
+
nativeTruncated,
|
|
78
|
+
});
|
|
162
79
|
}
|
|
163
80
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Build the workspace tree shown in the system prompt. Returns the rendered
|
|
83
|
+
* tree plus the AGENTS.md files surfaced by the same native walk so callers
|
|
84
|
+
* never need to do a second filesystem scan.
|
|
85
|
+
*/
|
|
86
|
+
export async function buildWorkspaceTree(cwd: string, options: BuildWorkspaceTreeOptions = {}): Promise<WorkspaceTree> {
|
|
87
|
+
const rootPath = path.resolve(cwd);
|
|
88
|
+
try {
|
|
89
|
+
const result = await listWorkspace({
|
|
90
|
+
path: rootPath,
|
|
91
|
+
maxDepth: WORKSPACE_DEFAULTS.maxDepth,
|
|
92
|
+
hidden: false,
|
|
93
|
+
gitignore: true,
|
|
94
|
+
collectAgentsMd: true,
|
|
95
|
+
timeoutMs: options.timeoutMs,
|
|
96
|
+
});
|
|
97
|
+
const tree = assembleTree(rootPath, result.entries, {
|
|
98
|
+
perDirLimit: WORKSPACE_DEFAULTS.perDirLimit,
|
|
99
|
+
rootLimit: WORKSPACE_DEFAULTS.perDirLimit,
|
|
100
|
+
lineCap: WORKSPACE_DEFAULTS.lineCap,
|
|
101
|
+
nativeTruncated: result.truncated,
|
|
102
|
+
});
|
|
103
|
+
return { ...tree, agentsMdFiles: result.agentsMdFiles };
|
|
104
|
+
} catch {
|
|
105
|
+
return { ...emptyTree(rootPath), agentsMdFiles: [] };
|
|
176
106
|
}
|
|
177
|
-
|
|
178
|
-
const result = await glob({
|
|
179
|
-
pattern: directChildPattern(parent.relativePath),
|
|
180
|
-
path: rootPath,
|
|
181
|
-
recursive: false,
|
|
182
|
-
hidden: options.hidden,
|
|
183
|
-
gitignore: true,
|
|
184
|
-
cache: options.cache,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
return result.matches
|
|
188
|
-
.map(match => matchChildName(parent.relativePath, match.path))
|
|
189
|
-
.filter((name): name is string => name !== null);
|
|
190
107
|
}
|
|
191
108
|
|
|
192
|
-
|
|
193
|
-
rootPath: string,
|
|
194
|
-
parent: DirectoryTreeNode,
|
|
195
|
-
options: ResolvedDirectoryTreeOptions,
|
|
196
|
-
): Promise<DirectoryTreeNode[]> {
|
|
197
|
-
const childNames = await listDirectChildNames(rootPath, parent, options);
|
|
109
|
+
// ─── internals ──────────────────────────────────────────────────────────────
|
|
198
110
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return {
|
|
210
|
-
name,
|
|
211
|
-
relativePath,
|
|
212
|
-
depth: parent.depth + 1,
|
|
213
|
-
isDirectory,
|
|
214
|
-
mtimeMs: stat.mtimeMs,
|
|
215
|
-
size: stat.size,
|
|
216
|
-
children: [],
|
|
217
|
-
droppedChildCount: 0,
|
|
218
|
-
} satisfies DirectoryTreeNode;
|
|
219
|
-
} catch {
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
}),
|
|
223
|
-
);
|
|
111
|
+
interface Node {
|
|
112
|
+
name: string;
|
|
113
|
+
isDir: boolean;
|
|
114
|
+
mtimeMs: number;
|
|
115
|
+
size: number;
|
|
116
|
+
depth: number;
|
|
117
|
+
children: Node[];
|
|
118
|
+
/** When > 0, `children` is laid out as `[recent…, oldest]`. */
|
|
119
|
+
droppedCount: number;
|
|
120
|
+
}
|
|
224
121
|
|
|
225
|
-
|
|
122
|
+
interface RenderedLine {
|
|
123
|
+
label: string;
|
|
124
|
+
depth: number;
|
|
125
|
+
isRoot: boolean;
|
|
126
|
+
size?: string;
|
|
127
|
+
age?: string;
|
|
226
128
|
}
|
|
227
129
|
|
|
228
|
-
|
|
229
|
-
|
|
130
|
+
interface AssembleOptions {
|
|
131
|
+
perDirLimit: number | null;
|
|
132
|
+
rootLimit: number | null;
|
|
133
|
+
lineCap: number | null;
|
|
134
|
+
nativeTruncated: boolean;
|
|
230
135
|
}
|
|
231
136
|
|
|
232
|
-
function
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
137
|
+
function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: AssembleOptions): DirectoryTree {
|
|
138
|
+
// Bucket entries by parent path. The native walker may yield siblings in
|
|
139
|
+
// any order across worker threads, so we group by string key and sort once
|
|
140
|
+
// per directory below.
|
|
141
|
+
const byParent = new Map<string, Node[]>();
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
const slash = entry.path.lastIndexOf("/");
|
|
144
|
+
const name = slash === -1 ? entry.path : entry.path.slice(slash + 1);
|
|
145
|
+
const parentPath = slash === -1 ? "" : entry.path.slice(0, slash);
|
|
146
|
+
const node: Node = {
|
|
147
|
+
name,
|
|
148
|
+
isDir: entry.fileType === FileType.Dir,
|
|
149
|
+
mtimeMs: entry.mtime ?? 0,
|
|
150
|
+
size: entry.size ?? 0,
|
|
151
|
+
depth: parentPath ? parentPath.split("/").length + 1 : 1,
|
|
152
|
+
children: [],
|
|
153
|
+
droppedCount: 0,
|
|
245
154
|
};
|
|
155
|
+
const bucket = byParent.get(parentPath);
|
|
156
|
+
if (bucket) bucket.push(node);
|
|
157
|
+
else byParent.set(parentPath, [node]);
|
|
246
158
|
}
|
|
247
159
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async function collectDirectoryTree(
|
|
257
|
-
rootPath: string,
|
|
258
|
-
options: ResolvedDirectoryTreeOptions,
|
|
259
|
-
): Promise<{ root: DirectoryTreeNode; truncated: boolean }> {
|
|
260
|
-
const rootStat = await Bun.file(rootPath).stat();
|
|
261
|
-
const root: DirectoryTreeNode = {
|
|
262
|
-
name: options.rootLabel,
|
|
263
|
-
relativePath: "",
|
|
160
|
+
const root: Node = {
|
|
161
|
+
name: ".",
|
|
162
|
+
isDir: true,
|
|
163
|
+
mtimeMs: 0,
|
|
164
|
+
size: 0,
|
|
264
165
|
depth: 0,
|
|
265
|
-
isDirectory: true,
|
|
266
|
-
mtimeMs: rootStat.mtimeMs,
|
|
267
|
-
size: rootStat.size,
|
|
268
166
|
children: [],
|
|
269
|
-
|
|
167
|
+
droppedCount: 0,
|
|
270
168
|
};
|
|
271
169
|
|
|
272
|
-
let truncated =
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (child.isDirectory) queue.push(child);
|
|
170
|
+
let truncated = opts.nativeTruncated;
|
|
171
|
+
const stack: Array<{ node: Node; relPath: string }> = [{ node: root, relPath: "" }];
|
|
172
|
+
while (stack.length > 0) {
|
|
173
|
+
const { node, relPath } = stack.pop()!;
|
|
174
|
+
const all = (byParent.get(relPath) ?? []).slice().sort(byRecency);
|
|
175
|
+
const limit = node.depth === 0 ? opts.rootLimit : opts.perDirLimit;
|
|
176
|
+
if (limit !== null && all.length > limit) {
|
|
177
|
+
node.children = limit <= 1 ? all.slice(0, Math.max(0, limit)) : [...all.slice(0, limit - 1), all.at(-1)!];
|
|
178
|
+
node.droppedCount = all.length - limit;
|
|
179
|
+
truncated = true;
|
|
180
|
+
} else {
|
|
181
|
+
node.children = all;
|
|
182
|
+
}
|
|
183
|
+
for (const child of node.children) {
|
|
184
|
+
if (!child.isDir) continue;
|
|
185
|
+
stack.push({ node: child, relPath: relPath ? `${relPath}/${child.name}` : child.name });
|
|
289
186
|
}
|
|
290
187
|
}
|
|
291
188
|
|
|
292
|
-
|
|
189
|
+
const rawLines: RenderedLine[] = [];
|
|
190
|
+
renderNode(root, Date.now(), rawLines);
|
|
191
|
+
const { lines, elidedCount } = applyLineCap(rawLines, opts.lineCap);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
rootPath,
|
|
195
|
+
rendered: formatLines(lines),
|
|
196
|
+
truncated: truncated || elidedCount > 0,
|
|
197
|
+
totalLines: lines.length,
|
|
198
|
+
};
|
|
293
199
|
}
|
|
294
200
|
|
|
295
|
-
function
|
|
296
|
-
|
|
297
|
-
return formatAge(ageSeconds);
|
|
201
|
+
function byRecency(a: Node, b: Node): number {
|
|
202
|
+
return b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name);
|
|
298
203
|
}
|
|
299
204
|
|
|
300
|
-
function
|
|
205
|
+
function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
|
|
301
206
|
if (node.depth === 0) {
|
|
302
|
-
|
|
303
|
-
|
|
207
|
+
out.push({ label: node.name, depth: 0, isRoot: true });
|
|
208
|
+
} else {
|
|
209
|
+
const indent = " ".repeat(node.depth);
|
|
210
|
+
const suffix = node.isDir ? "/" : "";
|
|
211
|
+
out.push({
|
|
212
|
+
label: `${indent}- ${node.name}${suffix}`,
|
|
213
|
+
depth: node.depth,
|
|
214
|
+
isRoot: false,
|
|
215
|
+
size: node.isDir ? undefined : formatBytes(node.size),
|
|
216
|
+
age: formatAge(Math.max(0, Math.floor((nowMs - node.mtimeMs) / 1000))),
|
|
217
|
+
});
|
|
304
218
|
}
|
|
305
219
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
lines.push({
|
|
309
|
-
label: `${indent}- ${node.name}${suffix}`,
|
|
310
|
-
depth: node.depth,
|
|
311
|
-
size: node.isDirectory ? undefined : formatBytes(node.size),
|
|
312
|
-
age: formatNodeAge(nowMs, node.mtimeMs),
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function pushDroppedChildrenLine(lines: RenderLine[], parent: DirectoryTreeNode): void {
|
|
317
|
-
if (parent.droppedChildCount <= 0) return;
|
|
318
|
-
const childDepth = parent.depth + 1;
|
|
319
|
-
const indent = " ".repeat(childDepth);
|
|
320
|
-
lines.push({
|
|
321
|
-
label: `${indent}- … ${parent.droppedChildCount} more`,
|
|
322
|
-
depth: childDepth,
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function collectRenderLines(node: DirectoryTreeNode, nowMs: number, lines: RenderLine[]): void {
|
|
327
|
-
pushNodeLine(lines, node, nowMs);
|
|
328
|
-
|
|
329
|
-
if (node.droppedChildCount > 0) {
|
|
330
|
-
const recentChildren = node.children.slice(0, -1);
|
|
331
|
-
const oldestChild = node.children[node.children.length - 1];
|
|
332
|
-
for (const child of recentChildren) collectRenderLines(child, nowMs, lines);
|
|
333
|
-
pushDroppedChildrenLine(lines, node);
|
|
334
|
-
if (oldestChild && !recentChildren.includes(oldestChild)) collectRenderLines(oldestChild, nowMs, lines);
|
|
220
|
+
if (node.droppedCount === 0) {
|
|
221
|
+
for (const child of node.children) renderNode(child, nowMs, out);
|
|
335
222
|
return;
|
|
336
223
|
}
|
|
337
224
|
|
|
338
|
-
|
|
225
|
+
// Layout: recent children, then "… N more" marker, then the oldest child.
|
|
226
|
+
const recent = node.children.slice(0, -1);
|
|
227
|
+
const oldest = node.children.at(-1);
|
|
228
|
+
for (const child of recent) renderNode(child, nowMs, out);
|
|
229
|
+
const childDepth = node.depth + 1;
|
|
230
|
+
out.push({
|
|
231
|
+
label: `${" ".repeat(childDepth)}- … ${node.droppedCount} more`,
|
|
232
|
+
depth: childDepth,
|
|
233
|
+
isRoot: false,
|
|
234
|
+
});
|
|
235
|
+
if (oldest) renderNode(oldest, nowMs, out);
|
|
339
236
|
}
|
|
340
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Cap the rendered tree at `lineCap` lines by removing the deepest trailing
|
|
240
|
+
* entries first. Root and root children (depth ≤ 1) are always preserved so
|
|
241
|
+
* the structural overview stays intact.
|
|
242
|
+
*/
|
|
341
243
|
function applyLineCap(
|
|
342
|
-
lines:
|
|
343
|
-
|
|
344
|
-
): { lines:
|
|
345
|
-
if (
|
|
244
|
+
lines: readonly RenderedLine[],
|
|
245
|
+
lineCap: number | null,
|
|
246
|
+
): { lines: RenderedLine[]; elidedCount: number } {
|
|
247
|
+
if (lineCap === null || lines.length <= lineCap) return { lines: [...lines], elidedCount: 0 };
|
|
346
248
|
|
|
347
|
-
const
|
|
348
|
-
const
|
|
249
|
+
const PROTECTED_DEPTH = 1;
|
|
250
|
+
const target = Math.max(1, lineCap - 1);
|
|
349
251
|
const removable = lines
|
|
350
252
|
.map((line, index) => ({ line, index }))
|
|
351
|
-
.filter(
|
|
253
|
+
.filter(({ line }) => !line.isRoot && line.depth > PROTECTED_DEPTH)
|
|
352
254
|
.sort((a, b) => b.line.depth - a.line.depth || b.index - a.index)
|
|
353
|
-
.slice(0,
|
|
354
|
-
if (removable.length === 0) return { lines, elidedCount: 0 };
|
|
255
|
+
.slice(0, lines.length - target);
|
|
256
|
+
if (removable.length === 0) return { lines: [...lines], elidedCount: 0 };
|
|
355
257
|
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
258
|
+
const removed = new Set(removable.map(item => item.index));
|
|
259
|
+
const kept = lines.filter((_, index) => !removed.has(index));
|
|
260
|
+
kept.push({
|
|
359
261
|
label: `… (${removable.length} lines elided beyond depth/cap)`,
|
|
360
262
|
depth: 0,
|
|
263
|
+
isRoot: false,
|
|
361
264
|
});
|
|
362
|
-
|
|
363
|
-
return { lines: cappedLines, elidedCount: removable.length };
|
|
265
|
+
return { lines: kept, elidedCount: removable.length };
|
|
364
266
|
}
|
|
365
267
|
|
|
366
|
-
function
|
|
268
|
+
function formatLines(lines: readonly RenderedLine[]): string {
|
|
367
269
|
const maxLabelLength = lines.reduce((max, line) => Math.max(max, line.label.length), 0);
|
|
368
270
|
return lines
|
|
369
271
|
.map(line => {
|
|
@@ -374,113 +276,11 @@ function renderLines(lines: RenderLine[]): string {
|
|
|
374
276
|
.join("\n");
|
|
375
277
|
}
|
|
376
278
|
|
|
377
|
-
|
|
378
|
-
const resolvedRootPath = path.resolve(rootPath);
|
|
379
|
-
const resolvedOptions = resolveDirectoryTreeOptions(options);
|
|
380
|
-
const nowMs = Date.now();
|
|
381
|
-
const { root, truncated: directoryTruncated } = await collectDirectoryTree(resolvedRootPath, resolvedOptions);
|
|
382
|
-
const lines: RenderLine[] = [];
|
|
383
|
-
collectRenderLines(root, nowMs, lines);
|
|
384
|
-
const { lines: cappedLines, elidedCount } = applyLineCap(lines, resolvedOptions);
|
|
279
|
+
function emptyTree(rootPath: string): DirectoryTree {
|
|
385
280
|
return {
|
|
386
|
-
rootPath
|
|
387
|
-
rendered:
|
|
388
|
-
truncated:
|
|
389
|
-
totalLines:
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Build a `parentRelativePath` → child name index from a flat list of POSIX
|
|
395
|
-
* paths. Intermediate directory components are inferred from path segments;
|
|
396
|
-
* the index covers every ancestor directory implied by the input.
|
|
397
|
-
*/
|
|
398
|
-
function buildChildIndexFromPaths(paths: readonly string[]): Map<string, Set<string>> {
|
|
399
|
-
const index = new Map<string, Set<string>>();
|
|
400
|
-
const ensure = (parent: string): Set<string> => {
|
|
401
|
-
let bucket = index.get(parent);
|
|
402
|
-
if (!bucket) {
|
|
403
|
-
bucket = new Set<string>();
|
|
404
|
-
index.set(parent, bucket);
|
|
405
|
-
}
|
|
406
|
-
return bucket;
|
|
281
|
+
rootPath,
|
|
282
|
+
rendered: "",
|
|
283
|
+
truncated: false,
|
|
284
|
+
totalLines: 0,
|
|
407
285
|
};
|
|
408
|
-
for (const raw of paths) {
|
|
409
|
-
if (!raw) continue;
|
|
410
|
-
const normalized = raw.replace(/\\/g, "/");
|
|
411
|
-
const parts = normalized.split("/").filter(segment => segment.length > 0);
|
|
412
|
-
if (parts.length === 0) continue;
|
|
413
|
-
for (let i = 0; i < parts.length; i += 1) {
|
|
414
|
-
const parent = parts.slice(0, i).join("/");
|
|
415
|
-
const segment = parts[i];
|
|
416
|
-
if (segment !== undefined) ensure(parent).add(segment);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
return index;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const GIT_LS_FILES_TIMEOUT_MS = 3000;
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* List tracked + untracked-not-ignored files at `rootPath` via `git ls-files`.
|
|
426
|
-
* Returns `null` when git is unavailable, the directory is not inside a
|
|
427
|
-
* worktree, or the call fails / times out — caller falls back to native
|
|
428
|
-
* directory listing.
|
|
429
|
-
*/
|
|
430
|
-
async function tryListGitFiles(rootPath: string): Promise<string[] | null> {
|
|
431
|
-
const gitPath = $which("git");
|
|
432
|
-
if (!gitPath) return null;
|
|
433
|
-
const signal = AbortSignal.timeout(GIT_LS_FILES_TIMEOUT_MS);
|
|
434
|
-
try {
|
|
435
|
-
const child = Bun.spawn([gitPath, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
436
|
-
cwd: rootPath,
|
|
437
|
-
stdout: "pipe",
|
|
438
|
-
stderr: "pipe",
|
|
439
|
-
stdin: "ignore",
|
|
440
|
-
signal,
|
|
441
|
-
});
|
|
442
|
-
const [stdout, exitCode] = await Promise.all([
|
|
443
|
-
new Response(child.stdout as ReadableStream<Uint8Array>).text(),
|
|
444
|
-
child.exited,
|
|
445
|
-
]);
|
|
446
|
-
if (exitCode !== 0) return null;
|
|
447
|
-
if (!stdout) return [];
|
|
448
|
-
// `-z` separates entries with NUL; trailing NUL after final entry.
|
|
449
|
-
return stdout.split("\0").filter(entry => entry.length > 0);
|
|
450
|
-
} catch (error) {
|
|
451
|
-
logger.debug("git ls-files failed; falling back to native directory listing", {
|
|
452
|
-
rootPath,
|
|
453
|
-
error: error instanceof Error ? error.message : String(error),
|
|
454
|
-
});
|
|
455
|
-
return null;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
|
|
460
|
-
const rootPath = path.resolve(cwd);
|
|
461
|
-
const baseOptions = {
|
|
462
|
-
maxDepth: WORKSPACE_TREE_MAX_DEPTH,
|
|
463
|
-
directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
|
|
464
|
-
lineCap: WORKSPACE_TREE_LINE_CAP,
|
|
465
|
-
excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
|
|
466
|
-
hidden: false,
|
|
467
|
-
cache: true,
|
|
468
|
-
rootLabel: ".",
|
|
469
|
-
} satisfies DirectoryTreeOptions;
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
const gitFiles = await tryListGitFiles(rootPath);
|
|
473
|
-
if (gitFiles !== null) {
|
|
474
|
-
// Git already applied gitignore + tracking semantics: bypass native
|
|
475
|
-
// recursive scan and feed the index directly to the tree builder.
|
|
476
|
-
return await buildDirectoryTree(rootPath, {
|
|
477
|
-
...baseOptions,
|
|
478
|
-
gitignore: false,
|
|
479
|
-
childIndex: buildChildIndexFromPaths(gitFiles),
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
return await buildDirectoryTree(rootPath, { ...baseOptions, gitignore: true });
|
|
483
|
-
} catch {
|
|
484
|
-
return emptyWorkspaceTree(rootPath);
|
|
485
|
-
}
|
|
486
286
|
}
|