@llm4ts/flow 0.13.5 → 0.14.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.
package/src/Workspace.ts CHANGED
@@ -17,13 +17,34 @@ export interface WorkspaceLimits {
17
17
  readonly maxWriteBytes: number
18
18
  readonly maxResults: number
19
19
  readonly maxDepth: number
20
+ /**
21
+ * Directory names discovery never descends into, at any depth. Version
22
+ * control internals and dependency/build output are never estate sources,
23
+ * yet on a real repository they hold the overwhelming majority of files —
24
+ * walking them is slow and used to spend the result cap before the first
25
+ * source was seen. Absent means no pruning (the previous behaviour).
26
+ */
27
+ readonly excludeDirs?: ReadonlyArray<string>
20
28
  }
21
29
 
30
+ /** The directories `defaultWorkspaceLimits` prunes from discovery. */
31
+ export const defaultExcludedDirectories: ReadonlyArray<string> = Object.freeze([
32
+ ".git",
33
+ ".hg",
34
+ ".svn",
35
+ "node_modules",
36
+ "target",
37
+ "build",
38
+ "dist",
39
+ "out"
40
+ ])
41
+
22
42
  export const defaultWorkspaceLimits: WorkspaceLimits = Object.freeze({
23
43
  maxReadBytes: 1_048_576,
24
44
  maxWriteBytes: 1_048_576,
25
45
  maxResults: 1_000,
26
- maxDepth: 32
46
+ maxDepth: 32,
47
+ excludeDirs: defaultExcludedDirectories
27
48
  })
28
49
 
29
50
  /**
@@ -32,30 +53,105 @@ export const defaultWorkspaceLimits: WorkspaceLimits = Object.freeze({
32
53
  * and generated exports, so the default 1 MiB read cap — sized for
33
54
  * spec-and-plan repositories — would fail an inventory on its first big
34
55
  * file. 8 MiB accommodates real estates while still refusing runaway blobs.
56
+ * The same reasoning sizes the discovery cap: an estate is by nature large,
57
+ * and 1 000 results — sized for spec-and-plan repositories — is fewer files
58
+ * than a mid-sized J2EE application ships in `src/` alone.
35
59
  */
36
60
  export const legacySourceWorkspaceLimits: WorkspaceLimits = Object.freeze({
37
61
  ...defaultWorkspaceLimits,
38
- maxReadBytes: 8_388_608
62
+ maxReadBytes: 8_388_608,
63
+ maxResults: 20_000
39
64
  })
40
65
 
66
+ const positiveInteger = (raw: string | undefined): number | undefined => {
67
+ const text = raw?.trim()
68
+ if (text === undefined || text.length === 0 || !/^\d+$/.test(text)) {
69
+ return undefined
70
+ }
71
+ const parsed = Number.parseInt(text, 10)
72
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined
73
+ }
74
+
75
+ const directoryList = (raw: string | undefined): ReadonlyArray<string> | undefined => {
76
+ const text = raw?.trim()
77
+ if (text === undefined || text.length === 0) {
78
+ return undefined
79
+ }
80
+ const names = text
81
+ .split(",")
82
+ .map((name) => name.trim())
83
+ .filter((name) => name.length > 0 && !name.includes("/") && !name.includes("\\"))
84
+ return names.length === 0 ? undefined : names
85
+ }
86
+
41
87
  /**
42
- * `defaults` with the per-file read cap overridden by `LLM4TS_MAX_READ_BYTES`
43
- * when it holds a positive integer; anything else leaves the defaults
44
- * untouched. The escape hatch for estates whose sources exceed even the
45
- * legacy-source cap.
88
+ * `defaults` with the caps overridden from the environment when the values
89
+ * are well-formed; anything else leaves that default untouched:
90
+ *
91
+ * - `LLM4TS_MAX_READ_BYTES` (positive integer): the per-file read cap, the
92
+ * escape hatch for estates whose sources exceed even the legacy-source cap.
93
+ * - `LLM4TS_MAX_DISCOVER_RESULTS` (positive integer): the discovery result
94
+ * cap, for estates larger than `legacySourceWorkspaceLimits` allows for.
95
+ * - `LLM4TS_EXCLUDE_DIRS` (comma-separated directory names): replaces the
96
+ * pruned-directory list — `.git,node_modules,generated` — for estates
97
+ * whose vendored or generated trees sit under names the default list
98
+ * does not know.
46
99
  */
47
100
  export const workspaceLimitsFromEnv = (
48
101
  environment: Readonly<Record<string, string | undefined>>,
49
102
  defaults: WorkspaceLimits = defaultWorkspaceLimits
50
103
  ): WorkspaceLimits => {
51
- const raw = environment.LLM4TS_MAX_READ_BYTES?.trim()
52
- if (raw === undefined || raw.length === 0 || !/^\d+$/.test(raw)) {
104
+ const maxReadBytes = positiveInteger(environment.LLM4TS_MAX_READ_BYTES)
105
+ const maxResults = positiveInteger(environment.LLM4TS_MAX_DISCOVER_RESULTS)
106
+ const excludeDirs = directoryList(environment.LLM4TS_EXCLUDE_DIRS)
107
+ if (maxReadBytes === undefined && maxResults === undefined && excludeDirs === undefined) {
53
108
  return defaults
54
109
  }
55
- const parsed = Number.parseInt(raw, 10)
56
- return Number.isSafeInteger(parsed) && parsed > 0
57
- ? { ...defaults, maxReadBytes: parsed }
58
- : defaults
110
+ return {
111
+ ...defaults,
112
+ ...(maxReadBytes === undefined ? {} : { maxReadBytes }),
113
+ ...(maxResults === undefined ? {} : { maxResults }),
114
+ ...(excludeDirs === undefined ? {} : { excludeDirs })
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Narrows a discovery beyond its glob: only paths `matching` the first regex
120
+ * and not `excluding` the second are returned AND counted against
121
+ * `maxResults`, so a cap sized for source units is not spent on the jars,
122
+ * images, and generated files that share the tree. Pass regexes without the
123
+ * `g` flag — a global regex carries `lastIndex` state across `test` calls.
124
+ */
125
+ export interface DiscoverOptions {
126
+ readonly matching?: RegExp
127
+ readonly excluding?: RegExp
128
+ }
129
+
130
+ export const discoverAccepts = (path: string, options: DiscoverOptions): boolean =>
131
+ (options.matching?.test(path) ?? true) && !(options.excluding?.test(path) ?? false)
132
+
133
+ /** Whether a repo-relative path lies under a pruned directory. */
134
+ export const isExcludedPath = (path: string, limits: WorkspaceLimits): boolean => {
135
+ const excluded = limits.excludeDirs
136
+ if (excluded === undefined || excluded.length === 0) {
137
+ return false
138
+ }
139
+ const directories = path.split("/").slice(0, -1)
140
+ return directories.some((segment) => excluded.includes(segment))
141
+ }
142
+
143
+ /**
144
+ * The advice a flow gives when discovery overflows `limits.maxResults`: the
145
+ * three knobs that narrow or raise it, in the order a user should try them.
146
+ */
147
+ export const discoveryOverflowAdvice = (limits: WorkspaceLimits): string => {
148
+ const pruned = limits.excludeDirs ?? []
149
+ return (
150
+ `discovery stopped at ${limits.maxResults} matching files; ` +
151
+ "narrow the pack's `sources:` regex or add an `exclude:` regex, prune more directories " +
152
+ `with LLM4TS_EXCLUDE_DIRS=<names> (pruned now: ${pruned.length === 0 ? "none" : pruned.join(",")}), ` +
153
+ "or raise the cap with LLM4TS_MAX_DISCOVER_RESULTS=<count>"
154
+ )
59
155
  }
60
156
 
61
157
  export interface WorkspaceShape {
@@ -64,7 +160,10 @@ export interface WorkspaceShape {
64
160
  readonly read: (path: string) => Effect.Effect<string, WorkspaceError>
65
161
  readonly write: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
66
162
  readonly append: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
67
- readonly discover: (pattern?: string) => Effect.Effect<ReadonlyArray<string>, WorkspaceError>
163
+ readonly discover: (
164
+ pattern?: string,
165
+ options?: DiscoverOptions
166
+ ) => Effect.Effect<ReadonlyArray<string>, WorkspaceError>
68
167
  readonly search: (
69
168
  query: string,
70
169
  pattern?: string
@@ -210,13 +309,19 @@ export const makeMemoryWorkspace = (
210
309
  )
211
310
  }
212
311
 
213
- const discover = (pattern = "**/*"): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
312
+ const discover = (
313
+ pattern = "**/*",
314
+ options: DiscoverOptions = {}
315
+ ): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
214
316
  Effect.gen(function* () {
215
317
  const normalizedPattern = pattern.replaceAll("\\", "/")
216
318
  const matcher = normalizedPattern === "**/*" ? /.*/ : globRegex(normalizedPattern)
217
319
  const files = yield* Ref.get(state)
218
320
  const results: Array<string> = []
219
321
  for (const path of Object.keys(files).sort(comparePaths)) {
322
+ if (isExcludedPath(path, limits)) {
323
+ continue
324
+ }
220
325
  const depth = path.split("/").length - 1
221
326
  if (depth > limits.maxDepth) {
222
327
  return yield* WorkspaceLimitError.make({
@@ -225,7 +330,7 @@ export const makeMemoryWorkspace = (
225
330
  actual: depth
226
331
  })
227
332
  }
228
- if (matcher.test(path)) {
333
+ if (matcher.test(path) && discoverAccepts(path, options)) {
229
334
  results.push(path)
230
335
  if (results.length > limits.maxResults) {
231
336
  return yield* WorkspaceLimitError.make({