@pruddiman/hem 0.0.1-beta-5671db0

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agents/arbiter-agent.d.ts +72 -0
  3. package/dist/agents/arbiter-agent.js +149 -0
  4. package/dist/agents/architecture-agent.d.ts +148 -0
  5. package/dist/agents/architecture-agent.js +459 -0
  6. package/dist/agents/base-agent.d.ts +44 -0
  7. package/dist/agents/base-agent.js +57 -0
  8. package/dist/agents/crossref-agent.d.ts +140 -0
  9. package/dist/agents/crossref-agent.js +560 -0
  10. package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
  11. package/dist/agents/crossref-arbiter-agent.js +147 -0
  12. package/dist/agents/documentation-agent.d.ts +55 -0
  13. package/dist/agents/documentation-agent.js +159 -0
  14. package/dist/agents/exploration-agent.d.ts +58 -0
  15. package/dist/agents/exploration-agent.js +102 -0
  16. package/dist/agents/grouping-agent.d.ts +167 -0
  17. package/dist/agents/grouping-agent.js +557 -0
  18. package/dist/agents/index-agent.d.ts +86 -0
  19. package/dist/agents/index-agent.js +360 -0
  20. package/dist/agents/organization-agent.d.ts +144 -0
  21. package/dist/agents/organization-agent.js +607 -0
  22. package/dist/auth.d.ts +372 -0
  23. package/dist/auth.js +1072 -0
  24. package/dist/broadcast-mcp.d.ts +21 -0
  25. package/dist/broadcast-mcp.js +59 -0
  26. package/dist/changelog.d.ts +85 -0
  27. package/dist/changelog.js +223 -0
  28. package/dist/decision-queue.d.ts +173 -0
  29. package/dist/decision-queue.js +265 -0
  30. package/dist/diff-scope.d.ts +24 -0
  31. package/dist/diff-scope.js +28 -0
  32. package/dist/discovery.d.ts +54 -0
  33. package/dist/discovery.js +405 -0
  34. package/dist/grouping.d.ts +37 -0
  35. package/dist/grouping.js +343 -0
  36. package/dist/helpers/format.d.ts +5 -0
  37. package/dist/helpers/format.js +13 -0
  38. package/dist/helpers/index.d.ts +11 -0
  39. package/dist/helpers/index.js +11 -0
  40. package/dist/helpers/parsing.d.ts +52 -0
  41. package/dist/helpers/parsing.js +128 -0
  42. package/dist/helpers/paths.d.ts +41 -0
  43. package/dist/helpers/paths.js +67 -0
  44. package/dist/helpers/strings.d.ts +45 -0
  45. package/dist/helpers/strings.js +97 -0
  46. package/dist/index.d.ts +135 -0
  47. package/dist/index.js +1087 -0
  48. package/dist/merge-utils.d.ts +22 -0
  49. package/dist/merge-utils.js +34 -0
  50. package/dist/orchestrator.d.ts +194 -0
  51. package/dist/orchestrator.js +1169 -0
  52. package/dist/output.d.ts +106 -0
  53. package/dist/output.js +243 -0
  54. package/dist/progress.d.ts +228 -0
  55. package/dist/progress.js +644 -0
  56. package/dist/providers/copilot.d.ts +247 -0
  57. package/dist/providers/copilot.js +598 -0
  58. package/dist/providers/index.d.ts +15 -0
  59. package/dist/providers/index.js +12 -0
  60. package/dist/providers/opencode.d.ts +156 -0
  61. package/dist/providers/opencode.js +416 -0
  62. package/dist/providers/types.d.ts +156 -0
  63. package/dist/providers/types.js +16 -0
  64. package/dist/resources.d.ts +76 -0
  65. package/dist/resources.js +151 -0
  66. package/dist/search-index.d.ts +71 -0
  67. package/dist/search-index.js +187 -0
  68. package/dist/search-mcp.d.ts +25 -0
  69. package/dist/search-mcp.js +100 -0
  70. package/dist/server-utils.d.ts +56 -0
  71. package/dist/server-utils.js +135 -0
  72. package/dist/session.d.ts +227 -0
  73. package/dist/session.js +370 -0
  74. package/dist/types.d.ts +272 -0
  75. package/dist/types.js +5 -0
  76. package/dist/worktree.d.ts +82 -0
  77. package/dist/worktree.js +187 -0
  78. package/package.json +45 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * File grouping module for Hem.
3
+ *
4
+ * Analyses discovered source files and groups them by feature vertical
5
+ * (e.g., "user", "order") or architectural layer (e.g., controllers,
6
+ * services). Each file appears in at most one group; feature grouping
7
+ * takes priority over layer grouping.
8
+ *
9
+ * Reference: FR-003, data-model.md lines 93-108.
10
+ */
11
+ import { dirname } from "node:path";
12
+ import { toKebabCase } from "./helpers/strings.js";
13
+ // ── Layer detection ─────────────────────────────────────────────────────
14
+ /**
15
+ * Maps well-known file name suffixes to their architectural layer label.
16
+ * Order matters: first match wins.
17
+ */
18
+ const LAYER_SUFFIXES = [
19
+ { suffix: ".controller", label: "Controllers" },
20
+ { suffix: ".controllers", label: "Controllers" },
21
+ { suffix: ".service", label: "Services" },
22
+ { suffix: ".services", label: "Services" },
23
+ { suffix: ".repository", label: "Repositories" },
24
+ { suffix: ".repositories", label: "Repositories" },
25
+ { suffix: ".repo", label: "Repositories" },
26
+ { suffix: ".model", label: "Models" },
27
+ { suffix: ".models", label: "Models" },
28
+ { suffix: ".entity", label: "Models" },
29
+ { suffix: ".middleware", label: "Middleware" },
30
+ { suffix: ".guard", label: "Guards" },
31
+ { suffix: ".pipe", label: "Pipes" },
32
+ { suffix: ".interceptor", label: "Interceptors" },
33
+ { suffix: ".filter", label: "Filters" },
34
+ { suffix: ".decorator", label: "Decorators" },
35
+ { suffix: ".dto", label: "DTOs" },
36
+ { suffix: ".schema", label: "Schemas" },
37
+ { suffix: ".migration", label: "Migrations" },
38
+ { suffix: ".seed", label: "Seeds" },
39
+ { suffix: ".test", label: "Tests" },
40
+ { suffix: ".spec", label: "Tests" },
41
+ { suffix: ".util", label: "Utilities" },
42
+ { suffix: ".utils", label: "Utilities" },
43
+ { suffix: ".helper", label: "Utilities" },
44
+ { suffix: ".helpers", label: "Utilities" },
45
+ { suffix: ".config", label: "Configuration" },
46
+ { suffix: ".route", label: "Routes" },
47
+ { suffix: ".routes", label: "Routes" },
48
+ { suffix: ".router", label: "Routes" },
49
+ { suffix: ".component", label: "Components" },
50
+ { suffix: ".hook", label: "Hooks" },
51
+ { suffix: ".context", label: "Contexts" },
52
+ { suffix: ".provider", label: "Providers" },
53
+ { suffix: ".resolver", label: "Resolvers" },
54
+ { suffix: ".module", label: "Modules" },
55
+ ];
56
+ /**
57
+ * Well-known directory names that indicate an architectural layer.
58
+ */
59
+ const LAYER_DIRECTORIES = new Map([
60
+ ["controllers", "Controllers"],
61
+ ["controller", "Controllers"],
62
+ ["services", "Services"],
63
+ ["service", "Services"],
64
+ ["repositories", "Repositories"],
65
+ ["repository", "Repositories"],
66
+ ["repos", "Repositories"],
67
+ ["models", "Models"],
68
+ ["model", "Models"],
69
+ ["entities", "Models"],
70
+ ["entity", "Models"],
71
+ ["middleware", "Middleware"],
72
+ ["middlewares", "Middleware"],
73
+ ["guards", "Guards"],
74
+ ["pipes", "Pipes"],
75
+ ["interceptors", "Interceptors"],
76
+ ["filters", "Filters"],
77
+ ["decorators", "Decorators"],
78
+ ["dtos", "DTOs"],
79
+ ["dto", "DTOs"],
80
+ ["schemas", "Schemas"],
81
+ ["migrations", "Migrations"],
82
+ ["seeds", "Seeds"],
83
+ ["tests", "Tests"],
84
+ ["test", "Tests"],
85
+ ["__tests__", "Tests"],
86
+ ["spec", "Tests"],
87
+ ["utils", "Utilities"],
88
+ ["util", "Utilities"],
89
+ ["helpers", "Utilities"],
90
+ ["lib", "Utilities"],
91
+ ["config", "Configuration"],
92
+ ["configs", "Configuration"],
93
+ ["routes", "Routes"],
94
+ ["router", "Routes"],
95
+ ["routers", "Routes"],
96
+ ["components", "Components"],
97
+ ["hooks", "Hooks"],
98
+ ["contexts", "Contexts"],
99
+ ["providers", "Providers"],
100
+ ["resolvers", "Resolvers"],
101
+ ["modules", "Modules"],
102
+ ]);
103
+ // ── Helpers ─────────────────────────────────────────────────────────────
104
+ /**
105
+ * Finds the common parent directory for a set of file paths.
106
+ *
107
+ * @param files - Array of FileInfo objects.
108
+ * @returns The common parent directory (relative path), or `"."` for root.
109
+ */
110
+ export function commonDirectory(files) {
111
+ if (files.length === 0)
112
+ return ".";
113
+ const first = files[0];
114
+ if (files.length === 1)
115
+ return dirname(first.path);
116
+ const dirs = files.map((f) => dirname(f.path).split("/"));
117
+ const minLen = Math.min(...dirs.map((d) => d.length));
118
+ const common = [];
119
+ const head = dirs[0];
120
+ for (let i = 0; i < minLen; i++) {
121
+ const segment = head[i];
122
+ if (dirs.every((d) => d[i] === segment)) {
123
+ common.push(segment);
124
+ }
125
+ else {
126
+ break;
127
+ }
128
+ }
129
+ return common.length === 0 ? "." : common.join("/");
130
+ }
131
+ /**
132
+ * Extracts the file stem (name without the final extension).
133
+ * For multi-part suffixes like `user.controller.ts`, the stem is `user`.
134
+ */
135
+ function fileStem(relativePath) {
136
+ const base = relativePath.split("/").pop() ?? "";
137
+ const dotIndex = base.indexOf(".");
138
+ return dotIndex === -1 ? base : base.substring(0, dotIndex);
139
+ }
140
+ /**
141
+ * Returns the "name" portion of the file without known layer suffixes
142
+ * and without the actual file extension.
143
+ * E.g. `user.controller.ts` → the layer suffix is `.controller` → stem `user`.
144
+ * `helpers.ts` → no layer suffix → stem `helpers`.
145
+ */
146
+ function fileNameWithoutExtension(relativePath) {
147
+ const base = relativePath.split("/").pop() ?? "";
148
+ // Remove the last extension (.ts, .js, etc.)
149
+ const lastDot = base.lastIndexOf(".");
150
+ return lastDot === -1 ? base : base.substring(0, lastDot);
151
+ }
152
+ /**
153
+ * Detects the architectural layer for a file by checking its name
154
+ * against known suffixes.
155
+ *
156
+ * @returns The layer label (e.g., "Controllers") or `undefined`.
157
+ */
158
+ function detectLayer(relativePath) {
159
+ const nameNoExt = fileNameWithoutExtension(relativePath).toLowerCase();
160
+ for (const { suffix, label } of LAYER_SUFFIXES) {
161
+ if (nameNoExt.endsWith(suffix)) {
162
+ return label;
163
+ }
164
+ }
165
+ return undefined;
166
+ }
167
+ /**
168
+ * Detects the architectural layer for a file by checking its containing
169
+ * directory name against known layer directories.
170
+ *
171
+ * @returns The layer label or `undefined`.
172
+ */
173
+ function detectLayerByDirectory(relativePath) {
174
+ const segments = dirname(relativePath).split("/");
175
+ // Check from innermost to outermost directory
176
+ for (let i = segments.length - 1; i >= 0; i--) {
177
+ const segment = segments[i];
178
+ if (!segment)
179
+ continue;
180
+ const label = LAYER_DIRECTORIES.get(segment.toLowerCase());
181
+ if (label)
182
+ return label;
183
+ }
184
+ return undefined;
185
+ }
186
+ /**
187
+ * Extracts a feature name from a file's path by looking at directory
188
+ * segments. Returns the most specific (deepest) non-layer directory
189
+ * segment, or `undefined` if the file is at root level or only in
190
+ * layer directories.
191
+ */
192
+ function extractFeatureName(relativePath) {
193
+ const dir = dirname(relativePath);
194
+ if (dir === ".")
195
+ return undefined;
196
+ const segments = dir.split("/");
197
+ // Walk from deepest to shallowest, find first segment that is NOT
198
+ // a known layer directory
199
+ for (let i = segments.length - 1; i >= 0; i--) {
200
+ const original = segments[i];
201
+ if (!original)
202
+ continue;
203
+ if (!LAYER_DIRECTORIES.has(original.toLowerCase())) {
204
+ return original; // preserve original casing
205
+ }
206
+ }
207
+ return undefined;
208
+ }
209
+ /**
210
+ * Capitalises the first letter and replaces hyphens/underscores with
211
+ * spaces for display labels. E.g., `user-auth` → `User Auth`.
212
+ */
213
+ function toDisplayLabel(name) {
214
+ return name
215
+ .replace(/[-_]/g, " ")
216
+ .replace(/\b\w/g, (ch) => ch.toUpperCase());
217
+ }
218
+ // ── Main ────────────────────────────────────────────────────────────────
219
+ /**
220
+ * Groups discovered files by feature vertical or architectural layer.
221
+ *
222
+ * Grouping strategy:
223
+ * 1. Filter out binary files.
224
+ * 2. Attempt to assign each file to a **feature vertical** group
225
+ * based on its directory structure (e.g., files under `user/` →
226
+ * "User" feature group).
227
+ * 3. Files not assigned to a feature group are checked for
228
+ * **architectural layer** membership based on file name suffixes
229
+ * (e.g., `.controller.ts` → "Controllers" layer) or containing
230
+ * directory (e.g., `services/` → "Services").
231
+ * 4. Remaining files go into a catch-all "Other" group.
232
+ * 5. Each file appears in at most one group.
233
+ *
234
+ * @param files - Discovered files (may include binary files).
235
+ * @returns Array of `FileGroup` objects.
236
+ */
237
+ export function groupFiles(files) {
238
+ // Step 1: filter to non-binary files only
239
+ const textFiles = files.filter((f) => !f.isBinary);
240
+ if (textFiles.length === 0)
241
+ return [];
242
+ // Step 2: Build feature and layer buckets
243
+ const featureBuckets = new Map();
244
+ const layerBuckets = new Map();
245
+ const ungrouped = [];
246
+ const assigned = new Set(); // track by relative path
247
+ // First pass: try to assign every file to a feature vertical
248
+ for (const file of textFiles) {
249
+ const feature = extractFeatureName(file.path);
250
+ if (feature) {
251
+ const key = feature.toLowerCase();
252
+ if (!featureBuckets.has(key)) {
253
+ featureBuckets.set(key, []);
254
+ }
255
+ featureBuckets.get(key).push(file);
256
+ assigned.add(file.path);
257
+ }
258
+ }
259
+ // Promote single-file features back to unassigned — features with
260
+ // only one file aren't meaningful groups by themselves. They'll get
261
+ // a chance to be grouped by layer instead.
262
+ for (const [key, bucket] of featureBuckets) {
263
+ if (bucket.length < 2) {
264
+ for (const file of bucket) {
265
+ assigned.delete(file.path);
266
+ }
267
+ featureBuckets.delete(key);
268
+ }
269
+ }
270
+ // Second pass: unassigned files → try layer grouping
271
+ for (const file of textFiles) {
272
+ if (assigned.has(file.path))
273
+ continue;
274
+ const layer = detectLayer(file.path) ?? detectLayerByDirectory(file.path);
275
+ if (layer) {
276
+ if (!layerBuckets.has(layer)) {
277
+ layerBuckets.set(layer, []);
278
+ }
279
+ layerBuckets.get(layer).push(file);
280
+ assigned.add(file.path);
281
+ }
282
+ }
283
+ // Promote single-file layers back to ungrouped
284
+ for (const [key, bucket] of layerBuckets) {
285
+ if (bucket.length < 2) {
286
+ for (const file of bucket) {
287
+ assigned.delete(file.path);
288
+ }
289
+ layerBuckets.delete(key);
290
+ }
291
+ }
292
+ // Third pass: remaining → "Other"
293
+ for (const file of textFiles) {
294
+ if (!assigned.has(file.path)) {
295
+ ungrouped.push(file);
296
+ }
297
+ }
298
+ // Step 3: Convert buckets to FileGroup objects
299
+ const groups = [];
300
+ // Feature (vertical) groups
301
+ for (const [key, bucket] of featureBuckets) {
302
+ const displayLabel = toDisplayLabel(key);
303
+ groups.push({
304
+ id: toKebabCase(displayLabel) + "-feature",
305
+ label: displayLabel,
306
+ type: "vertical",
307
+ files: bucket.sort((a, b) => a.path.localeCompare(b.path)),
308
+ directory: commonDirectory(bucket),
309
+ });
310
+ }
311
+ // Layer (horizontal) groups
312
+ for (const [label, bucket] of layerBuckets) {
313
+ groups.push({
314
+ id: toKebabCase(label) + "-layer",
315
+ label,
316
+ type: "horizontal",
317
+ files: bucket.sort((a, b) => a.path.localeCompare(b.path)),
318
+ directory: commonDirectory(bucket),
319
+ });
320
+ }
321
+ // Catch-all "Other" group
322
+ if (ungrouped.length > 0) {
323
+ groups.push({
324
+ id: "other",
325
+ label: "Other",
326
+ type: "vertical",
327
+ files: ungrouped.sort((a, b) => a.path.localeCompare(b.path)),
328
+ directory: commonDirectory(ungrouped),
329
+ });
330
+ }
331
+ // Sort groups: verticals first, then horizontals, alphabetically within
332
+ groups.sort((a, b) => {
333
+ if (a.type !== b.type)
334
+ return a.type === "vertical" ? -1 : 1;
335
+ // "Other" always last within its type
336
+ if (a.id === "other")
337
+ return 1;
338
+ if (b.id === "other")
339
+ return -1;
340
+ return a.label.localeCompare(b.label);
341
+ });
342
+ return groups;
343
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Format elapsed time as human-readable string.
3
+ * @internal
4
+ */
5
+ export declare function formatElapsed(startMs: number, endMs: number): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Format elapsed time as human-readable string.
3
+ * @internal
4
+ */
5
+ export function formatElapsed(startMs, endMs) {
6
+ const totalSeconds = Math.round((endMs - startMs) / 1000);
7
+ if (totalSeconds < 60) {
8
+ return `${totalSeconds}s`;
9
+ }
10
+ const minutes = Math.floor(totalSeconds / 60);
11
+ const seconds = totalSeconds % 60;
12
+ return `${minutes}m ${seconds}s`;
13
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Barrel file for src/helpers — re-exports all public symbols from
3
+ * the helper modules for convenient single-point imports.
4
+ *
5
+ * Usage:
6
+ * import { toKebabCase, formatElapsed } from "./helpers/index.js";
7
+ */
8
+ export * from "./strings.js";
9
+ export * from "./paths.js";
10
+ export * from "./parsing.js";
11
+ export * from "./format.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Barrel file for src/helpers — re-exports all public symbols from
3
+ * the helper modules for convenient single-point imports.
4
+ *
5
+ * Usage:
6
+ * import { toKebabCase, formatElapsed } from "./helpers/index.js";
7
+ */
8
+ export * from "./strings.js";
9
+ export * from "./paths.js";
10
+ export * from "./parsing.js";
11
+ export * from "./format.js";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Text extraction and parsing utilities for Hem.
3
+ *
4
+ * Provides helpers for extracting structured content (Markdown, JSON,
5
+ * file lists, concepts) from LLM response text.
6
+ */
7
+ import type { MessagePart } from "../session.js";
8
+ /**
9
+ * Extracts the Markdown content from the OpenCode prompt response.
10
+ *
11
+ * Concatenates all text parts from the response, stripping any wrapping
12
+ * code fences if the LLM returned the content inside a fenced block.
13
+ *
14
+ * @param parts - The response parts from `promptAndWait()`.
15
+ * @returns The extracted Markdown string.
16
+ */
17
+ export declare function extractMarkdown(parts: Array<MessagePart>): string;
18
+ /**
19
+ * Extracts a JSON string from an LLM response that may contain preamble text.
20
+ *
21
+ * LLMs often prepend commentary (e.g., "Now I have all the information I need.")
22
+ * before the actual JSON payload. This function tries three strategies in order:
23
+ *
24
+ * 1. Fenced code block — extracts content from `` ```json ... ``` `` fences.
25
+ * 2. First brace / bracket — locates the first `{` or `[` and the last matching
26
+ * `}` or `]` to isolate the JSON object/array from surrounding text.
27
+ * 3. Entire string — falls back to returning the full input (for responses that
28
+ * are already pure JSON).
29
+ *
30
+ * @param response - The raw LLM response string.
31
+ * @returns The extracted JSON substring (not yet parsed).
32
+ */
33
+ export declare function extractJSON(response: string): string;
34
+ /**
35
+ * Extracts `relatedFiles` from generated Markdown content.
36
+ *
37
+ * Looks for the "Source Files" or "Related Files" section and extracts file
38
+ * paths from backtick-quoted entries (e.g., `` `src/user/controller.ts` ``).
39
+ *
40
+ * @param content - The generated Markdown content.
41
+ * @returns Array of relative file paths found in the Source Files section.
42
+ */
43
+ export declare function parseRelatedFiles(content: string): string[];
44
+ /**
45
+ * Extracts key concept names from section content.
46
+ *
47
+ * Looks for backtick-quoted identifiers as a heuristic for concepts.
48
+ *
49
+ * @param content - The section body text.
50
+ * @returns Array of unique concept strings.
51
+ */
52
+ export declare function extractConcepts(content: string): string[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Text extraction and parsing utilities for Hem.
3
+ *
4
+ * Provides helpers for extracting structured content (Markdown, JSON,
5
+ * file lists, concepts) from LLM response text.
6
+ */
7
+ /**
8
+ * Extracts the Markdown content from the OpenCode prompt response.
9
+ *
10
+ * Concatenates all text parts from the response, stripping any wrapping
11
+ * code fences if the LLM returned the content inside a fenced block.
12
+ *
13
+ * @param parts - The response parts from `promptAndWait()`.
14
+ * @returns The extracted Markdown string.
15
+ */
16
+ export function extractMarkdown(parts) {
17
+ const textParts = parts.filter((p) => p.type === "text" && typeof p.text === "string");
18
+ let content = textParts.map((p) => p.text).join("\n");
19
+ // Strip wrapping ```markdown ... ``` fences if present
20
+ const fencePattern = /^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/;
21
+ const match = content.match(fencePattern);
22
+ if (match) {
23
+ content = match[1];
24
+ }
25
+ // Strip preamble text before the first Markdown heading.
26
+ // LLMs sometimes produce commentary like "Now I have a thorough
27
+ // understanding of the codebase..." before the actual document.
28
+ const h1Match = content.search(/^# .+/m);
29
+ if (h1Match > 0) {
30
+ content = content.slice(h1Match);
31
+ }
32
+ return content.trim();
33
+ }
34
+ /**
35
+ * Extracts a JSON string from an LLM response that may contain preamble text.
36
+ *
37
+ * LLMs often prepend commentary (e.g., "Now I have all the information I need.")
38
+ * before the actual JSON payload. This function tries three strategies in order:
39
+ *
40
+ * 1. Fenced code block — extracts content from `` ```json ... ``` `` fences.
41
+ * 2. First brace / bracket — locates the first `{` or `[` and the last matching
42
+ * `}` or `]` to isolate the JSON object/array from surrounding text.
43
+ * 3. Entire string — falls back to returning the full input (for responses that
44
+ * are already pure JSON).
45
+ *
46
+ * @param response - The raw LLM response string.
47
+ * @returns The extracted JSON substring (not yet parsed).
48
+ */
49
+ export function extractJSON(response) {
50
+ // Strategy 1: fenced ```json block
51
+ const fencePattern = /```(?:json)?\s*\n([\s\S]*?)\n```/;
52
+ const fenceMatch = response.match(fencePattern);
53
+ if (fenceMatch) {
54
+ return fenceMatch[1];
55
+ }
56
+ // Strategy 2: locate first { or [ and matching last } or ]
57
+ const braceIdx = response.indexOf("{");
58
+ const bracketIdx = response.indexOf("[");
59
+ let startChar = null;
60
+ let startIdx = -1;
61
+ if (braceIdx >= 0 && (bracketIdx < 0 || braceIdx < bracketIdx)) {
62
+ startChar = "{";
63
+ startIdx = braceIdx;
64
+ }
65
+ else if (bracketIdx >= 0) {
66
+ startChar = "[";
67
+ startIdx = bracketIdx;
68
+ }
69
+ if (startChar !== null && startIdx >= 0) {
70
+ const endChar = startChar === "{" ? "}" : "]";
71
+ const endIdx = response.lastIndexOf(endChar);
72
+ if (endIdx > startIdx) {
73
+ return response.slice(startIdx, endIdx + 1);
74
+ }
75
+ }
76
+ // Strategy 3: return full string (caller's JSON.parse will validate)
77
+ return response;
78
+ }
79
+ /**
80
+ * Extracts `relatedFiles` from generated Markdown content.
81
+ *
82
+ * Looks for the "Source Files" or "Related Files" section and extracts file
83
+ * paths from backtick-quoted entries (e.g., `` `src/user/controller.ts` ``).
84
+ *
85
+ * @param content - The generated Markdown content.
86
+ * @returns Array of relative file paths found in the Source Files section.
87
+ */
88
+ export function parseRelatedFiles(content) {
89
+ const files = [];
90
+ const lines = content.split("\n");
91
+ let inSection = false;
92
+ for (const line of lines) {
93
+ // Detect start of Source Files / Related Files section
94
+ if (/^##\s+(Source|Related) Files/i.test(line)) {
95
+ inSection = true;
96
+ continue;
97
+ }
98
+ // End section at next heading
99
+ if (inSection && /^##\s+/.test(line)) {
100
+ break;
101
+ }
102
+ // Extract file paths from backtick-quoted list items
103
+ if (inSection) {
104
+ const match = line.match(/`([^`]+)`/);
105
+ if (match) {
106
+ files.push(match[1]);
107
+ }
108
+ }
109
+ }
110
+ return files;
111
+ }
112
+ /**
113
+ * Extracts key concept names from section content.
114
+ *
115
+ * Looks for backtick-quoted identifiers as a heuristic for concepts.
116
+ *
117
+ * @param content - The section body text.
118
+ * @returns Array of unique concept strings.
119
+ */
120
+ export function extractConcepts(content) {
121
+ const matches = content.match(/`([^`]+)`/g);
122
+ if (!matches)
123
+ return [];
124
+ const unique = new Set(matches
125
+ .map((m) => m.slice(1, -1))
126
+ .filter((c) => c.length > 0 && !/^\s|\s$/.test(c) && !c.includes(" ")));
127
+ return Array.from(unique);
128
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Path validation and resolution utilities for Hem.
3
+ *
4
+ * Provides helpers for checking path containment, resolving output
5
+ * paths within a destination directory, and validating source/destination
6
+ * relationships.
7
+ */
8
+ /**
9
+ * Checks whether `child` is within (or equal to) `parent` after
10
+ * resolving both to absolute paths.
11
+ *
12
+ * Uses a trailing-separator prefix check to prevent false positives
13
+ * where `/foo/bar` would incorrectly match `/foo/barbaz`.
14
+ *
15
+ * @param child - The path to test.
16
+ * @param parent - The potential ancestor path.
17
+ * @returns `true` if `child` is the same as or a descendant of `parent`.
18
+ */
19
+ export declare function isPathWithin(child: string, parent: string): boolean;
20
+ /**
21
+ * Resolves the full output path for a file within the destination directory.
22
+ *
23
+ * Safety: Rejects any `relativePath` that would escape the destination
24
+ * directory (e.g., paths containing `..` segments that resolve outside).
25
+ *
26
+ * @param destinationPath - Path to the destination directory.
27
+ * @param relativePath - Relative path for the output file.
28
+ * @returns The resolved absolute output path.
29
+ * @throws If the resolved path falls outside the destination directory.
30
+ */
31
+ export declare function resolveOutputPath(destinationPath: string, relativePath: string): string;
32
+ /**
33
+ * Validates the relationship between destination and source paths.
34
+ *
35
+ * When the destination directory is inside the source directory, logs
36
+ * a note that destination files will be excluded from scanning.
37
+ *
38
+ * @param destinationPath - Path to the destination directory.
39
+ * @param sourcePath - Path to the source directory.
40
+ */
41
+ export declare function validateDestinationPath(destinationPath: string, sourcePath: string): void;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Path validation and resolution utilities for Hem.
3
+ *
4
+ * Provides helpers for checking path containment, resolving output
5
+ * paths within a destination directory, and validating source/destination
6
+ * relationships.
7
+ */
8
+ import { resolve, normalize, relative } from "node:path";
9
+ /**
10
+ * Checks whether `child` is within (or equal to) `parent` after
11
+ * resolving both to absolute paths.
12
+ *
13
+ * Uses a trailing-separator prefix check to prevent false positives
14
+ * where `/foo/bar` would incorrectly match `/foo/barbaz`.
15
+ *
16
+ * @param child - The path to test.
17
+ * @param parent - The potential ancestor path.
18
+ * @returns `true` if `child` is the same as or a descendant of `parent`.
19
+ */
20
+ export function isPathWithin(child, parent) {
21
+ const absoluteChild = resolve(child);
22
+ const absoluteParent = resolve(parent);
23
+ if (absoluteChild === absoluteParent) {
24
+ return true;
25
+ }
26
+ const parentPrefix = absoluteParent.endsWith("/")
27
+ ? absoluteParent
28
+ : absoluteParent + "/";
29
+ return absoluteChild.startsWith(parentPrefix);
30
+ }
31
+ /**
32
+ * Resolves the full output path for a file within the destination directory.
33
+ *
34
+ * Safety: Rejects any `relativePath` that would escape the destination
35
+ * directory (e.g., paths containing `..` segments that resolve outside).
36
+ *
37
+ * @param destinationPath - Path to the destination directory.
38
+ * @param relativePath - Relative path for the output file.
39
+ * @returns The resolved absolute output path.
40
+ * @throws If the resolved path falls outside the destination directory.
41
+ */
42
+ export function resolveOutputPath(destinationPath, relativePath) {
43
+ const absoluteDestination = resolve(destinationPath);
44
+ const resolved = resolve(absoluteDestination, normalize(relativePath));
45
+ const rel = relative(absoluteDestination, resolved);
46
+ if (rel.startsWith("..") || resolve(absoluteDestination, rel) !== resolved) {
47
+ throw new Error(`Output path "${relativePath}" escapes the destination directory "${absoluteDestination}".`);
48
+ }
49
+ return resolved;
50
+ }
51
+ /**
52
+ * Validates the relationship between destination and source paths.
53
+ *
54
+ * When the destination directory is inside the source directory, logs
55
+ * a note that destination files will be excluded from scanning.
56
+ *
57
+ * @param destinationPath - Path to the destination directory.
58
+ * @param sourcePath - Path to the source directory.
59
+ */
60
+ export function validateDestinationPath(destinationPath, sourcePath) {
61
+ const absoluteDestination = resolve(destinationPath);
62
+ const absoluteSource = resolve(sourcePath);
63
+ if (isPathWithin(absoluteDestination, absoluteSource)) {
64
+ console.log(`Note: Destination "${absoluteDestination}" is inside source "${absoluteSource}". ` +
65
+ `Destination files will be excluded from scanning.`);
66
+ }
67
+ }