@oh-my-pi/pi-utils 15.10.12 → 15.11.1

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.
@@ -12,6 +12,7 @@ export * from "./json";
12
12
  export * as logger from "./logger";
13
13
  export * from "./mermaid-ascii";
14
14
  export * from "./mime";
15
+ export * from "./path-tree";
15
16
  export * from "./peek-file";
16
17
  export * as postmortem from "./postmortem";
17
18
  export * as procmgr from "./procmgr";
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Multi-level path tree shared by grouped file listings (find / grep / ast tools)
3
+ * and compaction file-operation lists.
4
+ *
5
+ * Flat path lists used to group by the *immediate* parent directory and print the
6
+ * full directory path in every header. For results spread across a deep tree — or
7
+ * rooted outside cwd, where paths stay absolute — that repeated the shared prefix
8
+ * on every line. The tree below folds single-child directory chains (so the common
9
+ * prefix collapses into one header) and nests the rest, charging the model one
10
+ * token per path segment instead of one per file.
11
+ */
12
+ /** True for `scheme://…` entries that have no meaningful directory structure. */
13
+ export declare function isUrlLikePath(filePath: string): boolean;
14
+ export interface PathTreeNode {
15
+ /** Direct file leaves, in first-seen order. */
16
+ files: Array<{
17
+ name: string;
18
+ key: string;
19
+ }>;
20
+ /** Dedup set for `files` (a glob can surface the same path twice on retry). */
21
+ fileNames: Set<string>;
22
+ /** Child directories, in first-seen order. */
23
+ subdirs: Array<{
24
+ name: string;
25
+ node: PathTreeNode;
26
+ }>;
27
+ /** Dedup index for `subdirs`. */
28
+ dirIndex: Map<string, PathTreeNode>;
29
+ }
30
+ export interface PathTreeInput {
31
+ /** Path string; absolute, cwd-relative, or url-like. Backslashes are normalized. */
32
+ path: string;
33
+ /** Whether the leaf itself is a directory (trailing-slash match from find). */
34
+ isDir: boolean;
35
+ /** Opaque key carried onto file events for section lookup. Defaults to `path`. */
36
+ key?: string;
37
+ }
38
+ /** One node emitted while walking the tree: a folded directory or a file leaf. */
39
+ export interface GroupedTreeEvent {
40
+ kind: "dir" | "file";
41
+ /** 0-based nesting depth (root children are depth 0). */
42
+ depth: number;
43
+ /** Folded chain for dirs (e.g. `a/b/c`, no trailing slash); basename for files. */
44
+ name: string;
45
+ /** File key for `kind === "file"`; empty string for directories. */
46
+ key: string;
47
+ }
48
+ /**
49
+ * Build a directory tree from a flat list of paths. URL-like entries are kept
50
+ * whole as root-level file leaves (they have no meaningful directory structure).
51
+ * Absolute paths carry a leading empty segment so they share a common `/` root
52
+ * and fold like any other prefix.
53
+ */
54
+ export declare function buildPathTree(entries: Iterable<PathTreeInput>): PathTreeNode;
55
+ /**
56
+ * Depth-first walk yielding directory and file events. Directories collapse their
57
+ * single-child chains (`a` → `a/b` → `a/b/c`) so a shared prefix becomes one
58
+ * header. Each node's direct files are emitted before its subdirectories, keeping
59
+ * a file unambiguously attached to the header above it.
60
+ */
61
+ export declare function walkPathTree(node: PathTreeNode, depth?: number): Generator<GroupedTreeEvent>;
62
+ /**
63
+ * Render a flat path list as a grouped, prefix-folded directory tree without
64
+ * per-file bodies (find-tool output shape, also used by compaction `<files>`
65
+ * lists). Single-child directory chains fold into one header (`# a/b/c/`),
66
+ * each level adds one `#`, and files are listed bare under the deepest
67
+ * directory header that owns them. Trailing-slash entries are directory
68
+ * leaves and keep their slash in the header.
69
+ *
70
+ * `annotate` receives each file's full original path and its return value is
71
+ * appended verbatim to the file line (e.g. ` (RW)`).
72
+ *
73
+ * Order follows the input: a directory appears when its first member is
74
+ * emitted, and a node's own files precede its subdirectories.
75
+ */
76
+ export declare function formatGroupedPaths(paths: readonly string[], annotate?: (path: string) => string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "15.10.12",
4
+ "version": "15.11.1",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "15.10.12",
34
+ "@oh-my-pi/pi-natives": "15.11.1",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./json";
12
12
  export * as logger from "./logger";
13
13
  export * from "./mermaid-ascii";
14
14
  export * from "./mime";
15
+ export * from "./path-tree";
15
16
  export * from "./peek-file";
16
17
  export * as postmortem from "./postmortem";
17
18
  export * as procmgr from "./procmgr";
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Multi-level path tree shared by grouped file listings (find / grep / ast tools)
3
+ * and compaction file-operation lists.
4
+ *
5
+ * Flat path lists used to group by the *immediate* parent directory and print the
6
+ * full directory path in every header. For results spread across a deep tree — or
7
+ * rooted outside cwd, where paths stay absolute — that repeated the shared prefix
8
+ * on every line. The tree below folds single-child directory chains (so the common
9
+ * prefix collapses into one header) and nests the rest, charging the model one
10
+ * token per path segment instead of one per file.
11
+ */
12
+
13
+ const URL_LIKE_PATH_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
14
+
15
+ /** True for `scheme://…` entries that have no meaningful directory structure. */
16
+ export function isUrlLikePath(filePath: string): boolean {
17
+ return URL_LIKE_PATH_RE.test(filePath);
18
+ }
19
+
20
+ export interface PathTreeNode {
21
+ /** Direct file leaves, in first-seen order. */
22
+ files: Array<{ name: string; key: string }>;
23
+ /** Dedup set for `files` (a glob can surface the same path twice on retry). */
24
+ fileNames: Set<string>;
25
+ /** Child directories, in first-seen order. */
26
+ subdirs: Array<{ name: string; node: PathTreeNode }>;
27
+ /** Dedup index for `subdirs`. */
28
+ dirIndex: Map<string, PathTreeNode>;
29
+ }
30
+
31
+ export interface PathTreeInput {
32
+ /** Path string; absolute, cwd-relative, or url-like. Backslashes are normalized. */
33
+ path: string;
34
+ /** Whether the leaf itself is a directory (trailing-slash match from find). */
35
+ isDir: boolean;
36
+ /** Opaque key carried onto file events for section lookup. Defaults to `path`. */
37
+ key?: string;
38
+ }
39
+
40
+ /** One node emitted while walking the tree: a folded directory or a file leaf. */
41
+ export interface GroupedTreeEvent {
42
+ kind: "dir" | "file";
43
+ /** 0-based nesting depth (root children are depth 0). */
44
+ depth: number;
45
+ /** Folded chain for dirs (e.g. `a/b/c`, no trailing slash); basename for files. */
46
+ name: string;
47
+ /** File key for `kind === "file"`; empty string for directories. */
48
+ key: string;
49
+ }
50
+
51
+ function createNode(): PathTreeNode {
52
+ return { files: [], fileNames: new Set(), subdirs: [], dirIndex: new Map() };
53
+ }
54
+
55
+ function addFile(node: PathTreeNode, name: string, key: string): void {
56
+ if (node.fileNames.has(name)) return;
57
+ node.fileNames.add(name);
58
+ node.files.push({ name, key });
59
+ }
60
+
61
+ /**
62
+ * Build a directory tree from a flat list of paths. URL-like entries are kept
63
+ * whole as root-level file leaves (they have no meaningful directory structure).
64
+ * Absolute paths carry a leading empty segment so they share a common `/` root
65
+ * and fold like any other prefix.
66
+ */
67
+ export function buildPathTree(entries: Iterable<PathTreeInput>): PathTreeNode {
68
+ const root = createNode();
69
+ for (const { path: rawPath, isDir, key } of entries) {
70
+ const normalized = rawPath.replace(/\\/g, "/");
71
+ const fileKey = key ?? rawPath;
72
+ if (isUrlLikePath(normalized)) {
73
+ addFile(root, normalized, fileKey);
74
+ continue;
75
+ }
76
+ const trimmed = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
77
+ if (trimmed.length === 0) continue;
78
+ const segments = trimmed.split("/");
79
+ const dirCount = isDir ? segments.length : segments.length - 1;
80
+ let node = root;
81
+ for (let i = 0; i < dirCount; i++) {
82
+ const segment = segments[i]!;
83
+ let child = node.dirIndex.get(segment);
84
+ if (!child) {
85
+ child = createNode();
86
+ node.dirIndex.set(segment, child);
87
+ node.subdirs.push({ name: segment, node: child });
88
+ }
89
+ node = child;
90
+ }
91
+ if (!isDir) {
92
+ addFile(node, segments[segments.length - 1]!, fileKey);
93
+ }
94
+ }
95
+ return root;
96
+ }
97
+
98
+ /**
99
+ * Depth-first walk yielding directory and file events. Directories collapse their
100
+ * single-child chains (`a` → `a/b` → `a/b/c`) so a shared prefix becomes one
101
+ * header. Each node's direct files are emitted before its subdirectories, keeping
102
+ * a file unambiguously attached to the header above it.
103
+ */
104
+ export function* walkPathTree(node: PathTreeNode, depth = 0): Generator<GroupedTreeEvent> {
105
+ for (const file of node.files) {
106
+ yield { kind: "file", depth, name: file.name, key: file.key };
107
+ }
108
+ for (const subdir of node.subdirs) {
109
+ let dirNode = subdir.node;
110
+ const parts = [subdir.name];
111
+ while (dirNode.files.length === 0 && dirNode.subdirs.length === 1) {
112
+ const only = dirNode.subdirs[0]!;
113
+ parts.push(only.name);
114
+ dirNode = only.node;
115
+ }
116
+ yield { kind: "dir", depth, name: parts.join("/"), key: "" };
117
+ yield* walkPathTree(dirNode, depth + 1);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Render a flat path list as a grouped, prefix-folded directory tree without
123
+ * per-file bodies (find-tool output shape, also used by compaction `<files>`
124
+ * lists). Single-child directory chains fold into one header (`# a/b/c/`),
125
+ * each level adds one `#`, and files are listed bare under the deepest
126
+ * directory header that owns them. Trailing-slash entries are directory
127
+ * leaves and keep their slash in the header.
128
+ *
129
+ * `annotate` receives each file's full original path and its return value is
130
+ * appended verbatim to the file line (e.g. ` (RW)`).
131
+ *
132
+ * Order follows the input: a directory appears when its first member is
133
+ * emitted, and a node's own files precede its subdirectories.
134
+ */
135
+ export function formatGroupedPaths(paths: readonly string[], annotate?: (path: string) => string): string {
136
+ if (paths.length === 0) return "";
137
+ const tree = buildPathTree(paths.map(entry => ({ path: entry, isDir: entry.endsWith("/") })));
138
+ const lines: string[] = [];
139
+ for (const event of walkPathTree(tree)) {
140
+ if (event.kind === "dir") {
141
+ lines.push(`${"#".repeat(event.depth + 1)} ${event.name}/`);
142
+ } else {
143
+ lines.push(annotate ? `${event.name}${annotate(event.key)}` : event.name);
144
+ }
145
+ }
146
+ return lines.join("\n");
147
+ }
package/src/postmortem.ts CHANGED
@@ -38,7 +38,6 @@ function runCleanup(reason: Reason): Promise<void> {
38
38
  cleanupStage = "running";
39
39
  break;
40
40
  case "running":
41
- logger.error("Cleanup invoked recursively", { stack: new Error().stack });
42
41
  return Promise.resolve();
43
42
  case "complete":
44
43
  return Promise.resolve();
@@ -150,8 +149,9 @@ export function register(id: string, callback: (reason: Reason) => void | Promis
150
149
  };
151
150
 
152
151
  if (cleanupStage !== "idle") {
153
- // If cleanup is already running/completed, warn and run on microtask.
154
- logger.warn("Cleanup invoked recursively", { id });
152
+ // Cleanup is already in progress or complete; run late registrations once
153
+ // without re-entering the global cleanup pass.
154
+ logger.debug("Cleanup already started; running late callback once", { id });
155
155
  try {
156
156
  callback(Reason.MANUAL);
157
157
  } catch (e) {
package/src/prompt.ts CHANGED
@@ -342,10 +342,12 @@ handlebars.registerHelper(
342
342
  /**
343
343
  * {{join array ", "}}
344
344
  * Joins an array with a separator (default: ", ").
345
+ * Note: Use \n/\t in the separator for newlines/tabs (unescaped automatically,
346
+ * same convention as {{#list}} — Handlebars string literals carry no escapes).
345
347
  */
346
348
  handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => {
347
349
  if (!Array.isArray(context)) return "";
348
- const sep = typeof separator === "string" ? separator : ", ";
350
+ const sep = typeof separator === "string" ? separator.replace(/\\n/g, "\n").replace(/\\t/g, "\t") : ", ";
349
351
  return context.join(sep);
350
352
  });
351
353