@pruddiman/hem 0.0.1-beta-9f44128 → 0.0.1-beta-6f925fe

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.
@@ -14,6 +14,21 @@
14
14
  import type { Provider } from "../providers/types.js";
15
15
  import type { FileGroup, GenerationContext, ExplorationFindings } from "../types.js";
16
16
  import { BaseAgent } from "./base-agent.js";
17
+ /** Upper bound on the final prompt size before progressive section drops. */
18
+ export declare const MAX_PROMPT_CHARS = 500000;
19
+ /** Cap on per-group exploration findings text. */
20
+ export declare const MAX_FINDINGS_CHARS = 60000;
21
+ /** Cap on each individual existing-doc's embedded content. */
22
+ export declare const MAX_EXISTING_DOC_CHARS = 8000;
23
+ /** Cap on the aggregate existing-docs section (drops trailing docs beyond this). */
24
+ export declare const MAX_EXISTING_DOCS_SECTION_CHARS = 40000;
25
+ /** Cap on the cross-group findings summary. */
26
+ export declare const MAX_CROSS_GROUP_CHARS = 20000;
27
+ /**
28
+ * Truncate `text` to `max` chars, appending a short note explaining how to
29
+ * retrieve the rest. Returns the original text if already within budget.
30
+ */
31
+ export declare function truncateForPrompt(text: string, max: number, tailNote: string): string;
17
32
  /**
18
33
  * An agent that uses an LLM to generate documentation for a single
19
34
  * file group. The agent writes files directly via the edit tool.
@@ -12,6 +12,33 @@
12
12
  * - If existing docs are provided, the agent merges inline.
13
13
  */
14
14
  import { BaseAgent } from "./base-agent.js";
15
+ // ── Prompt-size budgets ─────────────────────────────────────────────────
16
+ //
17
+ // Copilot Sonnet has a ~168k token context window. Claude 1M is larger but
18
+ // we pick the smaller envelope for a one-size-fits-all cap. At ~4 chars per
19
+ // token the hard ceiling is ~670k chars; we target ~500k to leave headroom
20
+ // for the model's output tokens. Per-section caps keep any single section
21
+ // from dominating the prompt.
22
+ /** Upper bound on the final prompt size before progressive section drops. */
23
+ export const MAX_PROMPT_CHARS = 500_000;
24
+ /** Cap on per-group exploration findings text. */
25
+ export const MAX_FINDINGS_CHARS = 60_000;
26
+ /** Cap on each individual existing-doc's embedded content. */
27
+ export const MAX_EXISTING_DOC_CHARS = 8_000;
28
+ /** Cap on the aggregate existing-docs section (drops trailing docs beyond this). */
29
+ export const MAX_EXISTING_DOCS_SECTION_CHARS = 40_000;
30
+ /** Cap on the cross-group findings summary. */
31
+ export const MAX_CROSS_GROUP_CHARS = 20_000;
32
+ /**
33
+ * Truncate `text` to `max` chars, appending a short note explaining how to
34
+ * retrieve the rest. Returns the original text if already within budget.
35
+ */
36
+ export function truncateForPrompt(text, max, tailNote) {
37
+ if (text.length <= max)
38
+ return text;
39
+ return (text.slice(0, max).trimEnd() +
40
+ `\n\n[… truncated ${text.length - max} chars — ${tailNote}]`);
41
+ }
15
42
  // ── Agent ───────────────────────────────────────────────────────────────
16
43
  /**
17
44
  * An agent that uses an LLM to generate documentation for a single
@@ -37,6 +64,14 @@ export class DocumentationAgent extends BaseAgent {
37
64
  if (verbose) {
38
65
  verbose(`[${tag}] Prompt: ${prompt.length.toLocaleString()} chars`);
39
66
  }
67
+ if (prompt.length > MAX_PROMPT_CHARS) {
68
+ // Per-section caps should have prevented this; log loudly so we can
69
+ // investigate what's overflowing rather than silently failing at the
70
+ // provider's 168k-token barrier.
71
+ process.stderr.write(`[${tag}] WARNING: prompt ${prompt.length.toLocaleString()} chars exceeds ` +
72
+ `MAX_PROMPT_CHARS=${MAX_PROMPT_CHARS.toLocaleString()}. ` +
73
+ `Provider may reject. Investigate per-section truncation.\n`);
74
+ }
40
75
  // 2. Create a new session
41
76
  const sessionId = await this.createSession(`Hem: doc — ${group.label}`);
42
77
  if (verbose) {
@@ -84,7 +119,8 @@ export class DocumentationAgent extends BaseAgent {
84
119
  // 1. System-level instructions
85
120
  parts.push(`Generate documentation files that answer the questions the code asks — not`, `merely describe what the code does.`, "", `**You write files directly using the edit tool.** Do NOT return Markdown content`, `in your response text. Instead, use the edit tool to create and write files in`, `the destination directory. When you are done writing all files, stop.`, "");
86
121
  // 2. Where to write files
87
- parts.push("## Destination directory", "", `Write all documentation files under: \`${context.destinationPath}\``, "", `You have full autonomy over:`, `- **File naming**: Choose descriptive kebab-case filenames (e.g., \`user-authentication.md\`)`, `- **Directory structure**: Create subdirectories that make sense for this codebase`, `- **Number of files**: Create as many files as needed to properly document the group`, `- **File structure**: Design each document's heading hierarchy and sections`, "", `Guidelines for file organization:`, `- Choose a directory layout that reflects how the codebase is actually organized`, `- You may use flat files, subdirectories, or nested structures — whatever fits best`, `- Use \`.md\` extension for all files`, "");
122
+ const groupSubfolder = `${context.destinationPath}/${group.id}`;
123
+ parts.push("## Destination directory", "", `Write ALL documentation files for this group under this exact subfolder:`, "", ` \`${groupSubfolder}/\``, "", `**Do NOT** write outside this subfolder. **Do NOT** write to the root`, `\`${context.destinationPath}\` directory or any sibling group's subfolder.`, `This keeps the docs tree organized one-subfolder-per-group.`, "", `Within \`${groupSubfolder}/\` you have full autonomy over:`, `- **File naming**: Choose descriptive kebab-case filenames (e.g., \`overview.md\`, \`api-reference.md\`).`, `- **Nested subdirectories**: Create child folders inside the group subfolder if it helps (e.g., \`${groupSubfolder}/guides/getting-started.md\`).`, `- **Number of files**: Create as many files as needed to properly document the group.`, `- **File structure**: Design each document's heading hierarchy and sections.`, "", `Use \`.md\` extension for all files.`, "");
88
124
  // 3. Quality standards
89
125
  parts.push("## Quality Standard: Answer Every Question", "", "Your primary quality standard is: **every question from the exploration findings", "MUST be answered in the generated documentation.** Do NOT leave any question", "unanswered. If you cannot find a definitive answer, state what is known and what", "requires further investigation — but NEVER silently skip a question.", "", "For each integration discovered in the exploration findings:", "", "1. **Use `webfetch` to research answers**: When an integration has an", " `officialDocsUrl`, fetch that URL to find authoritative answers.", "2. **Address HOW, not just WHAT**: Explain HOW to access, query, monitor,", " and troubleshoot each integration.", "3. **Answer operational questions**: Every `operationalQuestions` entry for each", " integration MUST be answered in the documentation.", "");
90
126
  parts.push("## Documentation quality standards", "");
@@ -103,7 +139,7 @@ export class DocumentationAgent extends BaseAgent {
103
139
  parts.push("## Exploration findings for this group", "");
104
140
  if (groupFindings) {
105
141
  parts.push("The exploration phase discovered these findings for your group:", "");
106
- parts.push(DocumentationAgent.formatFindings(groupFindings));
142
+ parts.push(truncateForPrompt(DocumentationAgent.formatFindings(groupFindings), MAX_FINDINGS_CHARS, "re-run hem exploration or inspect the source files directly"));
107
143
  }
108
144
  else {
109
145
  parts.push("No exploration findings available for this group. Use tools to read and analyze the source files directly.", "");
@@ -111,16 +147,33 @@ export class DocumentationAgent extends BaseAgent {
111
147
  // 6. Cross-group context
112
148
  parts.push("## Cross-group context", "");
113
149
  parts.push("Other groups discovered these integrations and dependencies that may relate to", "your group:", "");
114
- parts.push(DocumentationAgent.summarizeCrossGroupFindings(allFindings, group.id));
150
+ parts.push(truncateForPrompt(DocumentationAgent.summarizeCrossGroupFindings(allFindings, group.id), MAX_CROSS_GROUP_CHARS, "inspect sibling group docs directly with `cat`"));
115
151
  parts.push("");
116
152
  // 7. Existing docs — search-before-write with skip/update/create decisions
117
153
  if (context.existingDocs.length > 0 || (context.mentionedDocPaths && context.mentionedDocPaths.length > 0)) {
118
154
  parts.push("## Existing documentation in destination", "");
119
155
  parts.push("The destination directory already contains documentation files.", "**Before writing ANY file, you MUST search for related existing docs.**", "", "### Decision criteria for each topic you plan to document:", "", "1. **SKIP** — if an existing doc already covers the topic accurately and", " completely. Do NOT rewrite content that is already correct.", "2. **UPDATE** — if an existing doc covers the topic but is stale, incomplete,", " or missing sections. Update it **in place** using the edit tool. Preserve", " accurate content; fix or expand what is stale or missing.", "3. **CREATE** — if no existing doc covers the topic. Write a new file.", "", "**Content-only changes**: Do NOT rename, move, or delete existing files.", "Only modify file content.", "");
120
- // Full content for the most relevant docs
156
+ // Full content for the most relevant docs — with per-file + total
157
+ // caps so a single group's docs can't blow out the prompt window.
158
+ // `context.existingDocs` is already ranked by the search index, so
159
+ // when we hit the total budget we drop the trailing (least relevant)
160
+ // entries rather than truncating across the board.
121
161
  if (context.existingDocs.length > 0) {
122
- parts.push(`### Most relevant existing docs (${context.existingDocs.length} file${context.existingDocs.length === 1 ? "" : "s"}, full content)`, "");
162
+ const includedDocs = [];
163
+ let includedBytes = 0;
164
+ let droppedDocs = 0;
123
165
  for (const doc of context.existingDocs) {
166
+ const truncated = truncateForPrompt(doc.content, MAX_EXISTING_DOC_CHARS, `run \`cat ${doc.path}\` to read the full content`);
167
+ if (includedBytes + truncated.length > MAX_EXISTING_DOCS_SECTION_CHARS &&
168
+ includedDocs.length > 0) {
169
+ droppedDocs = context.existingDocs.length - includedDocs.length;
170
+ break;
171
+ }
172
+ includedDocs.push({ path: doc.path, content: truncated });
173
+ includedBytes += truncated.length;
174
+ }
175
+ parts.push(`### Most relevant existing docs (${includedDocs.length} of ${context.existingDocs.length} file${context.existingDocs.length === 1 ? "" : "s"}, full content${droppedDocs > 0 ? `; ${droppedDocs} omitted — read with \`cat\` as needed` : ""})`, "");
176
+ for (const doc of includedDocs) {
124
177
  parts.push(`#### \`${doc.path}\``);
125
178
  parts.push("```markdown");
126
179
  parts.push(doc.content);
package/dist/discovery.js CHANGED
@@ -26,6 +26,19 @@ const DEFAULT_IGNORE_PATTERNS = [
26
26
  "**/coverage/**",
27
27
  "**/.cache/**",
28
28
  "**/.tmp/**",
29
+ // Framework build output and on-disk caches. These produce noisy,
30
+ // non-source files that leaked into grouping when a project's own
31
+ // .gitignore wasn't picked up (e.g. monorepo inner packages).
32
+ "**/.next/**",
33
+ "**/.turbo/**",
34
+ "**/.vercel/**",
35
+ "**/.nuxt/**",
36
+ "**/.svelte-kit/**",
37
+ "**/.astro/**",
38
+ "**/.parcel-cache/**",
39
+ "**/.vite/**",
40
+ "**/out/**",
41
+ "**/storybook-static/**",
29
42
  ];
30
43
  /**
31
44
  * Known binary file extensions.
package/dist/grouping.js CHANGED
@@ -225,12 +225,25 @@ function toDisplayLabel(name) {
225
225
  .replace(/\b\w/g, (ch) => ch.toUpperCase());
226
226
  }
227
227
  // ── Main ────────────────────────────────────────────────────────────────
228
- /** Minimum files a top-level src directory needs before it's promoted. */
229
- const TOP_LEVEL_PROMOTION_THRESHOLD = 3;
228
+ /**
229
+ * Minimum files a top-level src directory needs before it's promoted.
230
+ *
231
+ * Raised from 3 to 6 after a real-world run produced 71 groups on a
232
+ * Next.js monorepo — every tiny infrastructure directory (heartbeat/,
233
+ * ngrok/, caddy/) was auto-promoted. A higher bar keeps those files
234
+ * flowing through to layer/component passes or "Other".
235
+ */
236
+ const TOP_LEVEL_PROMOTION_THRESHOLD = 6;
230
237
  /** Minimum size of an import-graph connected component to become a group. */
231
238
  const MIN_COMPONENT_SIZE = 2;
232
239
  /** Components larger than this split along directory boundaries. */
233
240
  const MAX_COMPONENT_SIZE = 6;
241
+ /**
242
+ * Maximum number of vertical groups before consolidation kicks in. When
243
+ * exceeded, the smallest non-pinned groups are merged into "Other" until
244
+ * the count drops back under this cap.
245
+ */
246
+ const MAX_VERTICAL_GROUPS = 20;
234
247
  /**
235
248
  * Groups discovered files. See module docstring for the priority order.
236
249
  *
@@ -269,7 +282,7 @@ export function groupFiles(files, options = {}) {
269
282
  addFeature(match.key, toDisplayLabel(match.name), file);
270
283
  pinnedKeys.add(match.key);
271
284
  }
272
- // ── Pass 2: src top-level promotion (≥3 files) ──
285
+ // ── Pass 2: src top-level promotion (≥TOP_LEVEL_PROMOTION_THRESHOLD files) ──
273
286
  const topLevelCounts = countTopLevelDirs(textFiles);
274
287
  for (const file of textFiles) {
275
288
  if (assigned.has(file.path))
@@ -279,6 +292,8 @@ export function groupFiles(files, options = {}) {
279
292
  continue;
280
293
  if (LAYER_DIRECTORIES.has(top.toLowerCase()))
281
294
  continue;
295
+ if (!isValidLabelCandidate(top))
296
+ continue;
282
297
  const count = topLevelCounts.get(top) ?? 0;
283
298
  if (count < TOP_LEVEL_PROMOTION_THRESHOLD)
284
299
  continue;
@@ -291,6 +306,8 @@ export function groupFiles(files, options = {}) {
291
306
  const feature = extractFeatureName(file.path);
292
307
  if (!feature)
293
308
  continue;
309
+ if (!isValidLabelCandidate(feature))
310
+ continue;
294
311
  addFeature(feature.toLowerCase(), toDisplayLabel(feature), file);
295
312
  }
296
313
  // Demote single-file feature buckets (unless pinned by a prior).
@@ -345,6 +362,26 @@ export function groupFiles(files, options = {}) {
345
362
  }
346
363
  // ── Pass 6: catch-all "Other" ──
347
364
  const ungrouped = textFiles.filter((f) => !assigned.has(f.path));
365
+ // ── Pass 7: consolidate if too many vertical groups ──
366
+ // Monorepos with many top-level feature dirs produce too many verticals
367
+ // to be useful. Fold the smallest non-pinned ones into "Other" until
368
+ // we're back under the cap. Priors stay pinned regardless.
369
+ // If consolidation runs, "Other" will exist afterward, so we target
370
+ // one fewer bucket to keep the total (buckets + Other) at the cap.
371
+ if (featureBuckets.size > MAX_VERTICAL_GROUPS - 1) {
372
+ const candidates = [...featureBuckets.entries()]
373
+ .filter(([key]) => !pinnedKeys.has(key))
374
+ .sort(([, a], [, b]) => a.length - b.length);
375
+ let excess = featureBuckets.size - (MAX_VERTICAL_GROUPS - 1);
376
+ for (const [key, bucket] of candidates) {
377
+ if (excess <= 0)
378
+ break;
379
+ ungrouped.push(...bucket);
380
+ featureBuckets.delete(key);
381
+ featureLabels.delete(key);
382
+ excess--;
383
+ }
384
+ }
348
385
  // ── Build FileGroup objects ──
349
386
  const groups = [];
350
387
  for (const [key, bucket] of featureBuckets) {
@@ -424,25 +461,58 @@ function buildComponentGroups(components, byPath) {
424
461
  if (files.length < MIN_COMPONENT_SIZE)
425
462
  continue;
426
463
  if (files.length <= MAX_COMPONENT_SIZE) {
427
- out.push(componentToGroup(files));
464
+ const group = componentToGroup(files);
465
+ if (group)
466
+ out.push(group);
428
467
  continue;
429
468
  }
430
469
  for (const sub of bisectByDirectory(files)) {
431
470
  if (sub.length < MIN_COMPONENT_SIZE)
432
471
  continue;
433
- out.push(componentToGroup(sub));
472
+ const group = componentToGroup(sub);
473
+ if (group)
474
+ out.push(group);
434
475
  }
435
476
  }
436
477
  return out;
437
478
  }
479
+ /**
480
+ * Build a vertical group from an import-graph component.
481
+ *
482
+ * Returns `null` when the component has no meaningful shared directory
483
+ * (root-level files) or when the derived basename would produce a
484
+ * degenerate label (leading dot, hash-only, empty). Rejected components
485
+ * fall through to the "Other" bucket rather than inventing a group name
486
+ * from a filename.
487
+ */
438
488
  function componentToGroup(files) {
439
489
  const commonDir = commonDirectory(files);
440
- const basename = commonDir.split("/").filter((s) => s.length > 0).pop() ?? "cluster";
490
+ if (commonDir === "." || commonDir === "")
491
+ return null;
492
+ const basename = commonDir.split("/").filter((s) => s.length > 0).pop() ?? "";
493
+ if (!isValidLabelCandidate(basename))
494
+ return null;
441
495
  const label = toDisplayLabel(basename);
442
- // Append a stable short hash of paths to avoid collisions with other buckets.
443
496
  const key = basename.toLowerCase() + "-cluster";
444
497
  return { key, label, files };
445
498
  }
499
+ /**
500
+ * Reject directory/label candidates that would produce junk group IDs:
501
+ * - leading dot (`.next`, `.turbo`, `.vite`) — build-output leaks.
502
+ * - empty string.
503
+ * - hash-like strings (≥12 consecutive lowercase-alnum chars with no
504
+ * vowels or separators) — Next.js dev-cache hash dirs.
505
+ */
506
+ function isValidLabelCandidate(name) {
507
+ if (!name)
508
+ return false;
509
+ if (name.startsWith("."))
510
+ return false;
511
+ const lower = name.toLowerCase();
512
+ if (/^[a-z0-9]{12,}$/.test(lower) && !/[aeiou]/.test(lower))
513
+ return false;
514
+ return true;
515
+ }
446
516
  /**
447
517
  * Split a large component into sub-groups by directory prefix. Files are
448
518
  * bucketed by their first directory segment; singletons collapse back into
@@ -35,7 +35,8 @@ export interface ImportAnalysis {
35
35
  }
36
36
  /**
37
37
  * Build the import graph for a set of files. Files that fail to read are
38
- * silently skipped (they contribute no edges).
38
+ * silently skipped (they contribute no edges). Reads run in parallel with
39
+ * a bounded concurrency to keep wall-clock time low on large projects.
39
40
  */
40
41
  export declare function buildImportGraph(files: FileInfo[]): Promise<ImportAnalysis>;
41
42
  /**
@@ -11,6 +11,15 @@
11
11
  * integration catalog with file:line citations.
12
12
  */
13
13
  import { readFile } from "node:fs/promises";
14
+ import pLimit from "p-limit";
15
+ /**
16
+ * Files larger than this byte count are skipped when building the import
17
+ * graph. Huge generated files (lockfiles, bundled output) rarely contain
18
+ * useful import edges and reading them can stall the pipeline for minutes.
19
+ */
20
+ const MAX_FILE_BYTES = 2 * 1024 * 1024; // 2 MB
21
+ /** Parallel file reads when building the graph. */
22
+ const READ_CONCURRENCY = 32;
14
23
  // ── Regexes ─────────────────────────────────────────────────────────────
15
24
  // Static: `import ... from "x"` or `export ... from "x"`
16
25
  const STATIC_RE = /(?:import|export)\s+[^;'"`]*?\s+from\s+["']([^"']+)["']/g;
@@ -19,21 +28,25 @@ const DYNAMIC_RE = /(?:import|require)\s*\(\s*["']([^"']+)["']\s*\)/g;
19
28
  // ── Public API ──────────────────────────────────────────────────────────
20
29
  /**
21
30
  * Build the import graph for a set of files. Files that fail to read are
22
- * silently skipped (they contribute no edges).
31
+ * silently skipped (they contribute no edges). Reads run in parallel with
32
+ * a bounded concurrency to keep wall-clock time low on large projects.
23
33
  */
24
34
  export async function buildImportGraph(files) {
25
35
  const known = new Set(files.map((f) => f.path));
26
36
  const localEdges = new Map();
27
37
  const externalImports = new Map();
28
- for (const file of files) {
38
+ const limit = pLimit(READ_CONCURRENCY);
39
+ await Promise.all(files.map((file) => limit(async () => {
29
40
  if (file.isBinary)
30
- continue;
41
+ return;
42
+ if (file.size > MAX_FILE_BYTES)
43
+ return;
31
44
  let content;
32
45
  try {
33
46
  content = await readFile(file.absolutePath, "utf-8");
34
47
  }
35
48
  catch {
36
- continue;
49
+ return;
37
50
  }
38
51
  const local = [];
39
52
  const external = [];
@@ -55,7 +68,7 @@ export async function buildImportGraph(files) {
55
68
  if (external.length > 0) {
56
69
  externalImports.set(file.path, external);
57
70
  }
58
- }
71
+ })));
59
72
  return { localEdges, externalImports };
60
73
  }
61
74
  /**
package/dist/index.js CHANGED
@@ -519,9 +519,14 @@ export async function handleGenerate(opts, deps = defaultDeps) {
519
519
  .map((p) => p.name)
520
520
  .join(", ")}`);
521
521
  }
522
+ if (cliOptions.verbose) {
523
+ verboseLog(`[grouping] building import graph from ${textFiles.length} files...`);
524
+ }
525
+ const importGraphStart = Date.now();
522
526
  const importAnalysis = await buildImportGraph(textFiles);
523
527
  if (cliOptions.verbose) {
524
- verboseLog(`[grouping] import graph: ${importAnalysis.localEdges.size} files with local edges, ` +
528
+ const elapsed = ((Date.now() - importGraphStart) / 1000).toFixed(1);
529
+ verboseLog(`[grouping] import graph built in ${elapsed}s: ${importAnalysis.localEdges.size} files with local edges, ` +
525
530
  `${importAnalysis.externalImports.size} with external imports`);
526
531
  }
527
532
  const groups = deps.groupFiles(textFiles, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pruddiman/hem",
3
- "version": "0.0.1-beta-9f44128",
3
+ "version": "0.0.1-beta-6f925fe",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hem": "./dist/index.js"