@oh-my-pi/pi-coding-agent 14.6.6 → 14.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/hooks/handoff.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/sdk/03-custom-prompt.ts +7 -4
  5. package/examples/sdk/README.md +1 -1
  6. package/package.json +7 -7
  7. package/src/autoresearch/index.ts +48 -44
  8. package/src/cli/read-cli.ts +58 -0
  9. package/src/cli.ts +1 -0
  10. package/src/commands/read.ts +40 -0
  11. package/src/commit/agentic/agent.ts +1 -1
  12. package/src/commit/analysis/conventional.ts +1 -1
  13. package/src/commit/analysis/summary.ts +1 -1
  14. package/src/commit/changelog/generate.ts +1 -1
  15. package/src/commit/map-reduce/map-phase.ts +1 -1
  16. package/src/commit/map-reduce/reduce-phase.ts +1 -1
  17. package/src/config/settings-schema.ts +39 -0
  18. package/src/edit/line-hash.ts +34 -4
  19. package/src/edit/modes/hashline.ts +201 -6
  20. package/src/edit/streaming.ts +4 -1
  21. package/src/export/html/index.ts +1 -1
  22. package/src/extensibility/extensions/runner.ts +3 -3
  23. package/src/extensibility/extensions/types.ts +4 -4
  24. package/src/main.ts +3 -3
  25. package/src/memories/index.ts +1 -1
  26. package/src/modes/components/agent-dashboard.ts +1 -1
  27. package/src/modes/components/read-tool-group.ts +4 -9
  28. package/src/modes/components/tool-execution.ts +4 -0
  29. package/src/modes/controllers/event-controller.ts +2 -0
  30. package/src/modes/rpc/rpc-types.ts +1 -1
  31. package/src/modes/utils/context-usage.ts +12 -5
  32. package/src/modes/utils/ui-helpers.ts +1 -0
  33. package/src/prompts/system/project-prompt.md +36 -0
  34. package/src/prompts/system/system-prompt.md +0 -29
  35. package/src/prompts/tools/github.md +1 -0
  36. package/src/prompts/tools/read.md +15 -14
  37. package/src/sdk.ts +29 -28
  38. package/src/session/agent-session.ts +20 -12
  39. package/src/session/compaction/branch-summarization.ts +1 -1
  40. package/src/session/compaction/compaction.ts +3 -3
  41. package/src/session/session-dump-format.ts +10 -5
  42. package/src/session/streaming-output.ts +1 -1
  43. package/src/system-prompt.ts +35 -3
  44. package/src/task/executor.ts +4 -3
  45. package/src/tools/fetch.ts +4 -4
  46. package/src/tools/gh.ts +187 -0
  47. package/src/tools/inspect-image.ts +1 -1
  48. package/src/tools/output-meta.ts +1 -1
  49. package/src/tools/path-utils.ts +11 -0
  50. package/src/tools/read.ts +388 -204
  51. package/src/tools/search.ts +1 -1
  52. package/src/tools/sqlite-reader.ts +1 -1
  53. package/src/utils/commit-message-generator.ts +1 -1
  54. package/src/utils/title-generator.ts +1 -1
  55. package/src/web/search/providers/anthropic.ts +1 -1
  56. package/src/workspace-tree.ts +396 -0
@@ -50,7 +50,7 @@ const searchSchema = Type.Object({
50
50
 
51
51
  export type SearchToolInput = Static<typeof searchSchema>;
52
52
 
53
- const DEFAULT_MATCH_LIMIT = 500;
53
+ export const DEFAULT_MATCH_LIMIT = 100;
54
54
 
55
55
  export interface SearchToolDetails {
56
56
  truncation?: TruncationResult;
@@ -726,7 +726,7 @@ export function renderTable(
726
726
  parts.push(
727
727
  truncateToWidth(
728
728
  replaceTabs(
729
- `[${remaining} more rows; use sel="${meta.table}?limit=${meta.limit}&offset=${nextOffset}" to continue]`,
729
+ `[${remaining} more rows; append :${meta.table}?limit=${meta.limit}&offset=${nextOffset} to the database path to continue]`,
730
730
  ),
731
731
  MAX_RENDER_WIDTH,
732
732
  ),
@@ -102,7 +102,7 @@ export async function generateCommitMessage(
102
102
  const response = await completeSimple(
103
103
  candidate.model,
104
104
  {
105
- systemPrompt: COMMIT_SYSTEM_PROMPT,
105
+ systemPrompt: [COMMIT_SYSTEM_PROMPT],
106
106
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
107
107
  },
108
108
  { apiKey, maxTokens: 60, reasoning: toReasoningEffort(candidate.thinkingLevel) },
@@ -81,7 +81,7 @@ ${truncatedMessage}
81
81
  const response = await completeSimple(
82
82
  model,
83
83
  {
84
- systemPrompt: request.systemPrompt,
84
+ systemPrompt: [request.systemPrompt],
85
85
  messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
86
86
  },
87
87
  {
@@ -63,7 +63,7 @@ function buildSystemBlocks(
63
63
  const includeClaudeCode = !model.startsWith("claude-3-5-haiku");
64
64
  const extraInstructions = auth.isOAuth ? ["You are a helpful AI assistant with web search capabilities."] : [];
65
65
 
66
- return buildAnthropicSystemBlocks(systemPrompt, {
66
+ return buildAnthropicSystemBlocks(systemPrompt ? [systemPrompt] : undefined, {
67
67
  includeClaudeCodeInstruction: includeClaudeCode,
68
68
  extraInstructions,
69
69
  cacheControl: { type: "ephemeral" },
@@ -0,0 +1,396 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { glob } from "@oh-my-pi/pi-natives";
4
+ import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
5
+
6
+ export interface DirectoryTree {
7
+ rootPath: string;
8
+ rendered: string;
9
+ truncated: boolean;
10
+ totalLines: number;
11
+ }
12
+
13
+ export interface WorkspaceTree extends DirectoryTree {}
14
+
15
+ export interface DirectoryTreeOptions {
16
+ /** Directory depth below the root to include. Root itself is depth 0. */
17
+ maxDepth?: number;
18
+ /** Per-directory child cap. Use null to disable per-directory truncation. */
19
+ directoryEntryLimit?: number | null;
20
+ /** Optional root child cap. Defaults to directoryEntryLimit; use null to keep all root children. */
21
+ rootEntryLimit?: number | null;
22
+ /** Hard rendered line cap. Use null to disable line-cap pruning. */
23
+ 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
+
40
+ const WORKSPACE_TREE_MAX_DEPTH = 3;
41
+ const WORKSPACE_TREE_DIR_LIMIT = 12;
42
+ const WORKSPACE_TREE_LINE_CAP = 120;
43
+ const WORKSPACE_TREE_EXCLUDED_DIRS = new Set([
44
+ "node_modules",
45
+ ".git",
46
+ ".next",
47
+ "dist",
48
+ "build",
49
+ "target",
50
+ ".venv",
51
+ ".cache",
52
+ ".turbo",
53
+ ".parcel-cache",
54
+ "coverage",
55
+ ]);
56
+
57
+ const DIRECTORY_TREE_EXCLUDED_NAMES = new Set([".DS_Store"]);
58
+
59
+ const GLOB_SPECIAL_CHARS = new Set(["!", "(", ")", "*", "?", "[", "]", "{", "}", "\\"]);
60
+
61
+ interface DirectoryTreeNode {
62
+ name: string;
63
+ relativePath: string;
64
+ depth: number;
65
+ isDirectory: boolean;
66
+ mtimeMs: number;
67
+ size: number;
68
+ children: DirectoryTreeNode[];
69
+ droppedChildCount: number;
70
+ }
71
+
72
+ interface ResolvedDirectoryTreeOptions {
73
+ maxDepth: number;
74
+ directoryEntryLimit: number | null;
75
+ rootEntryLimit: number | null;
76
+ lineCap: number | null;
77
+ lineCapProtectedDepth: number;
78
+ excludedDirectoryNames: ReadonlySet<string>;
79
+ excludedNames: ReadonlySet<string>;
80
+ hidden: boolean;
81
+ gitignore: boolean;
82
+ cache: boolean;
83
+ rootLabel: string;
84
+ }
85
+
86
+ interface RenderLine {
87
+ label: string;
88
+ depth: number;
89
+ size?: string;
90
+ age?: string;
91
+ isRoot?: boolean;
92
+ }
93
+
94
+ function emptyWorkspaceTree(rootPath: string): WorkspaceTree {
95
+ return {
96
+ rootPath,
97
+ rendered: "",
98
+ truncated: false,
99
+ totalLines: 0,
100
+ };
101
+ }
102
+
103
+ function resolveDirectoryTreeOptions(options: DirectoryTreeOptions): ResolvedDirectoryTreeOptions {
104
+ const directoryEntryLimit = options.directoryEntryLimit === undefined ? null : options.directoryEntryLimit;
105
+ const rootEntryLimit = options.rootEntryLimit === undefined ? directoryEntryLimit : options.rootEntryLimit;
106
+ const excludedDirectoryNames =
107
+ options.excludedDirectoryNames instanceof Set
108
+ ? options.excludedDirectoryNames
109
+ : new Set(options.excludedDirectoryNames ?? []);
110
+ const providedExcludedNames =
111
+ options.excludedNames instanceof Set ? options.excludedNames : new Set(options.excludedNames ?? []);
112
+ const excludedNames = new Set([...DIRECTORY_TREE_EXCLUDED_NAMES, ...providedExcludedNames]);
113
+ return {
114
+ maxDepth: options.maxDepth ?? 1,
115
+ directoryEntryLimit,
116
+ rootEntryLimit,
117
+ lineCap: options.lineCap === undefined ? null : options.lineCap,
118
+ lineCapProtectedDepth: options.lineCapProtectedDepth ?? 0,
119
+ excludedDirectoryNames,
120
+ excludedNames,
121
+ hidden: options.hidden ?? true,
122
+ gitignore: options.gitignore ?? false,
123
+ cache: options.cache ?? true,
124
+ rootLabel: options.rootLabel ?? ".",
125
+ };
126
+ }
127
+
128
+ function compareByRecency(a: DirectoryTreeNode, b: DirectoryTreeNode): number {
129
+ const mtimeCompare = b.mtimeMs - a.mtimeMs;
130
+ if (mtimeCompare !== 0) return mtimeCompare;
131
+ return a.name.localeCompare(b.name);
132
+ }
133
+
134
+ function childRelativePath(parentRelativePath: string, name: string): string {
135
+ return parentRelativePath ? `${parentRelativePath}/${name}` : name;
136
+ }
137
+
138
+ function escapeGlobSegment(segment: string): string {
139
+ return Array.from(segment, char => (GLOB_SPECIAL_CHARS.has(char) ? `\\${char}` : char)).join("");
140
+ }
141
+
142
+ function directChildPattern(parentRelativePath: string): string {
143
+ if (!parentRelativePath) return "*";
144
+ return `${parentRelativePath.split("/").map(escapeGlobSegment).join("/")}/*`;
145
+ }
146
+
147
+ function matchChildName(parentRelativePath: string, matchPath: string): string | null {
148
+ if (!parentRelativePath) return matchPath.includes("/") ? null : matchPath;
149
+ const prefix = `${parentRelativePath}/`;
150
+ if (!matchPath.startsWith(prefix)) return null;
151
+ const name = matchPath.slice(prefix.length);
152
+ return name.includes("/") ? null : name;
153
+ }
154
+
155
+ async function listDirectChildNames(
156
+ rootPath: string,
157
+ parent: DirectoryTreeNode,
158
+ options: ResolvedDirectoryTreeOptions,
159
+ ): Promise<string[]> {
160
+ if (!options.gitignore) {
161
+ const directoryPath = parent.relativePath ? path.join(rootPath, parent.relativePath) : rootPath;
162
+ return await fs.readdir(directoryPath);
163
+ }
164
+
165
+ const result = await glob({
166
+ pattern: directChildPattern(parent.relativePath),
167
+ path: rootPath,
168
+ recursive: false,
169
+ hidden: options.hidden,
170
+ gitignore: true,
171
+ cache: options.cache,
172
+ });
173
+
174
+ return result.matches
175
+ .map(match => matchChildName(parent.relativePath, match.path))
176
+ .filter((name): name is string => name !== null);
177
+ }
178
+
179
+ async function listDirectoryTreeChildren(
180
+ rootPath: string,
181
+ parent: DirectoryTreeNode,
182
+ options: ResolvedDirectoryTreeOptions,
183
+ ): Promise<DirectoryTreeNode[]> {
184
+ const childNames = await listDirectChildNames(rootPath, parent, options);
185
+
186
+ const children = await Promise.all(
187
+ childNames.map(async (name): Promise<DirectoryTreeNode | null> => {
188
+ if (options.excludedNames.has(name)) return null;
189
+ if (!options.hidden && name.startsWith(".")) return null;
190
+ const relativePath = childRelativePath(parent.relativePath, name);
191
+ const absolutePath = path.join(rootPath, relativePath);
192
+ try {
193
+ const stat = await Bun.file(absolutePath).stat();
194
+ const isDirectory = stat.isDirectory();
195
+ if (isDirectory && options.excludedDirectoryNames.has(name)) return null;
196
+ return {
197
+ name,
198
+ relativePath,
199
+ depth: parent.depth + 1,
200
+ isDirectory,
201
+ mtimeMs: stat.mtimeMs,
202
+ size: stat.size,
203
+ children: [],
204
+ droppedChildCount: 0,
205
+ } satisfies DirectoryTreeNode;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }),
210
+ );
211
+
212
+ return children.filter((child): child is DirectoryTreeNode => child !== null).sort(compareByRecency);
213
+ }
214
+
215
+ function entryLimitForNode(node: DirectoryTreeNode, options: ResolvedDirectoryTreeOptions): number | null {
216
+ return node.depth === 0 ? options.rootEntryLimit : options.directoryEntryLimit;
217
+ }
218
+
219
+ function applyDirectoryLimit(
220
+ node: DirectoryTreeNode,
221
+ children: DirectoryTreeNode[],
222
+ options: ResolvedDirectoryTreeOptions,
223
+ ): { visibleChildren: DirectoryTreeNode[]; droppedCount: number } {
224
+ const entryLimit = entryLimitForNode(node, options);
225
+ if (entryLimit === null || children.length <= entryLimit) {
226
+ return { visibleChildren: children, droppedCount: 0 };
227
+ }
228
+ if (entryLimit <= 1) {
229
+ return {
230
+ visibleChildren: children.slice(0, Math.max(0, entryLimit)),
231
+ droppedCount: children.length - entryLimit,
232
+ };
233
+ }
234
+
235
+ const recentChildren = children.slice(0, entryLimit - 1);
236
+ const oldestChild = children[children.length - 1];
237
+ return {
238
+ visibleChildren: oldestChild ? [...recentChildren, oldestChild] : recentChildren,
239
+ droppedCount: children.length - entryLimit,
240
+ };
241
+ }
242
+
243
+ async function collectDirectoryTree(
244
+ rootPath: string,
245
+ options: ResolvedDirectoryTreeOptions,
246
+ ): Promise<{ root: DirectoryTreeNode; truncated: boolean }> {
247
+ const rootStat = await Bun.file(rootPath).stat();
248
+ const root: DirectoryTreeNode = {
249
+ name: options.rootLabel,
250
+ relativePath: "",
251
+ depth: 0,
252
+ isDirectory: true,
253
+ mtimeMs: rootStat.mtimeMs,
254
+ size: rootStat.size,
255
+ children: [],
256
+ droppedChildCount: 0,
257
+ };
258
+
259
+ let truncated = false;
260
+ const queue: DirectoryTreeNode[] = [root];
261
+ let cursor = 0;
262
+
263
+ while (cursor < queue.length) {
264
+ const parent = queue[cursor];
265
+ cursor += 1;
266
+ if (!parent || parent.depth >= options.maxDepth) continue;
267
+
268
+ const children = await listDirectoryTreeChildren(rootPath, parent, options);
269
+ const limited = applyDirectoryLimit(parent, children, options);
270
+ parent.children = limited.visibleChildren;
271
+ parent.droppedChildCount = limited.droppedCount;
272
+ if (limited.droppedCount > 0) truncated = true;
273
+
274
+ for (const child of parent.children) {
275
+ if (child.isDirectory) queue.push(child);
276
+ }
277
+ }
278
+
279
+ return { root, truncated };
280
+ }
281
+
282
+ function formatNodeAge(nowMs: number, mtimeMs: number): string {
283
+ const ageSeconds = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000));
284
+ return formatAge(ageSeconds);
285
+ }
286
+
287
+ function pushNodeLine(lines: RenderLine[], node: DirectoryTreeNode, nowMs: number): void {
288
+ if (node.depth === 0) {
289
+ lines.push({ label: node.name, depth: 0, isRoot: true });
290
+ return;
291
+ }
292
+
293
+ const indent = " ".repeat(node.depth);
294
+ const suffix = node.isDirectory ? "/" : "";
295
+ lines.push({
296
+ label: `${indent}- ${node.name}${suffix}`,
297
+ depth: node.depth,
298
+ size: node.isDirectory ? undefined : formatBytes(node.size),
299
+ age: formatNodeAge(nowMs, node.mtimeMs),
300
+ });
301
+ }
302
+
303
+ function pushDroppedChildrenLine(lines: RenderLine[], parent: DirectoryTreeNode): void {
304
+ if (parent.droppedChildCount <= 0) return;
305
+ const childDepth = parent.depth + 1;
306
+ const indent = " ".repeat(childDepth);
307
+ lines.push({
308
+ label: `${indent}- … ${parent.droppedChildCount} more`,
309
+ depth: childDepth,
310
+ });
311
+ }
312
+
313
+ function collectRenderLines(node: DirectoryTreeNode, nowMs: number, lines: RenderLine[]): void {
314
+ pushNodeLine(lines, node, nowMs);
315
+
316
+ if (node.droppedChildCount > 0) {
317
+ const recentChildren = node.children.slice(0, -1);
318
+ const oldestChild = node.children[node.children.length - 1];
319
+ for (const child of recentChildren) collectRenderLines(child, nowMs, lines);
320
+ pushDroppedChildrenLine(lines, node);
321
+ if (oldestChild && !recentChildren.includes(oldestChild)) collectRenderLines(oldestChild, nowMs, lines);
322
+ return;
323
+ }
324
+
325
+ for (const child of node.children) collectRenderLines(child, nowMs, lines);
326
+ }
327
+
328
+ function applyLineCap(
329
+ lines: RenderLine[],
330
+ options: ResolvedDirectoryTreeOptions,
331
+ ): { lines: RenderLine[]; elidedCount: number } {
332
+ if (options.lineCap === null || lines.length <= options.lineCap) return { lines, elidedCount: 0 };
333
+
334
+ const targetLineCount = Math.max(1, options.lineCap - 1);
335
+ const removeCount = lines.length - targetLineCount;
336
+ const removable = lines
337
+ .map((line, index) => ({ line, index }))
338
+ .filter(item => !item.line.isRoot && item.line.depth > options.lineCapProtectedDepth)
339
+ .sort((a, b) => b.line.depth - a.line.depth || b.index - a.index)
340
+ .slice(0, removeCount);
341
+ if (removable.length === 0) return { lines, elidedCount: 0 };
342
+
343
+ const removedIndexes = new Set(removable.map(item => item.index));
344
+ const cappedLines = lines.filter((_, index) => !removedIndexes.has(index));
345
+ cappedLines.push({
346
+ label: `… (${removable.length} lines elided beyond depth/cap)`,
347
+ depth: 0,
348
+ });
349
+
350
+ return { lines: cappedLines, elidedCount: removable.length };
351
+ }
352
+
353
+ function renderLines(lines: RenderLine[]): string {
354
+ const maxLabelLength = lines.reduce((max, line) => Math.max(max, line.label.length), 0);
355
+ return lines
356
+ .map(line => {
357
+ if (!line.age) return line.label;
358
+ const sizeColumn = (line.size ?? "").padEnd(8);
359
+ return `${line.label.padEnd(maxLabelLength + 2)}${sizeColumn} ${line.age.padEnd(4)}`.trimEnd();
360
+ })
361
+ .join("\n");
362
+ }
363
+
364
+ export async function buildDirectoryTree(rootPath: string, options: DirectoryTreeOptions = {}): Promise<DirectoryTree> {
365
+ const resolvedRootPath = path.resolve(rootPath);
366
+ const resolvedOptions = resolveDirectoryTreeOptions(options);
367
+ const nowMs = Date.now();
368
+ const { root, truncated: directoryTruncated } = await collectDirectoryTree(resolvedRootPath, resolvedOptions);
369
+ const lines: RenderLine[] = [];
370
+ collectRenderLines(root, nowMs, lines);
371
+ const { lines: cappedLines, elidedCount } = applyLineCap(lines, resolvedOptions);
372
+ return {
373
+ rootPath: resolvedRootPath,
374
+ rendered: renderLines(cappedLines),
375
+ truncated: directoryTruncated || elidedCount > 0,
376
+ totalLines: cappedLines.length,
377
+ };
378
+ }
379
+
380
+ export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
381
+ const rootPath = path.resolve(cwd);
382
+ try {
383
+ return await buildDirectoryTree(rootPath, {
384
+ maxDepth: WORKSPACE_TREE_MAX_DEPTH,
385
+ directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
386
+ lineCap: WORKSPACE_TREE_LINE_CAP,
387
+ excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
388
+ hidden: false,
389
+ gitignore: true,
390
+ cache: true,
391
+ rootLabel: ".",
392
+ });
393
+ } catch {
394
+ return emptyWorkspaceTree(rootPath);
395
+ }
396
+ }