langflower 0.0.6 → 0.0.8

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.
@@ -4,23 +4,15 @@ import { formatNotFound, resolveFenceRoot, resolveProjectPath, } from '../../pat
4
4
  import { applyPostProcess } from '../apply-post-process.js';
5
5
  import { asBoolean, asString } from '../args.js';
6
6
  import { displayPath, fenceOptions } from '../fence.js';
7
- import { walkFiles } from '../walk-files.js';
8
- const MAX_GREP_MATCHES = 100;
7
+ import { runGrepCascade } from './search.js';
9
8
  const invoke = async (ctx, args) => {
10
9
  const pattern = asString(args, 'pattern');
11
10
  if (pattern === undefined) {
12
11
  throw new Error('grep requires string argument «pattern».');
13
12
  }
14
- let regex;
15
- try {
16
- regex = new RegExp(pattern, asBoolean(args, 'caseInsensitive', false) ? 'i' : '');
17
- }
18
- catch (error) {
19
- const message = error instanceof Error ? error.message : String(error);
20
- throw new Error(`Invalid regex «${pattern}»: ${message}. Escape special characters or simplify the pattern.`);
21
- }
22
13
  const searchPath = asString(args, 'path') ?? '.';
23
14
  const respectGitignore = asBoolean(args, 'respectGitignore', true);
15
+ const caseInsensitive = asBoolean(args, 'caseInsensitive', false);
24
16
  const absolute = resolveProjectPath(ctx.projectRoot, searchPath, fenceOptions(ctx));
25
17
  const stat = await fs.stat(absolute).catch(() => null);
26
18
  if (stat === null) {
@@ -28,39 +20,15 @@ const invoke = async (ctx, args) => {
28
20
  }
29
21
  const fenceRoot = resolveFenceRoot(ctx.projectRoot, absolute, ctx.allowedRoots) ??
30
22
  path.resolve(ctx.projectRoot);
31
- const files = stat.isFile()
32
- ? [displayPath(ctx, absolute)]
33
- : (await walkFiles(fenceRoot, absolute, respectGitignore)).map((file) => displayPath(ctx, path.join(fenceRoot, file)));
34
- const hits = [];
35
- for (const file of files) {
36
- if (hits.length >= MAX_GREP_MATCHES) {
37
- break;
38
- }
39
- const fileAbs = resolveProjectPath(ctx.projectRoot, file, fenceOptions(ctx));
40
- let text;
41
- try {
42
- text = await fs.readFile(fileAbs, 'utf8');
43
- }
44
- catch {
45
- continue;
46
- }
47
- const lines = text.split(/\r?\n/);
48
- for (let i = 0; i < lines.length; i += 1) {
49
- const line = lines[i] ?? '';
50
- if (regex.test(line)) {
51
- hits.push(`${file}:${i + 1}:${line}`);
52
- if (hits.length >= MAX_GREP_MATCHES) {
53
- break;
54
- }
55
- }
56
- }
57
- }
58
- const body = hits.length === 0
59
- ? '(no matches)'
60
- : hits.join('\n') +
61
- (hits.length >= MAX_GREP_MATCHES
62
- ? `\n…[truncated at ${MAX_GREP_MATCHES}; refine pattern or path]`
63
- : '');
23
+ const { body } = await runGrepCascade({
24
+ pattern,
25
+ caseInsensitive,
26
+ respectGitignore,
27
+ searchAbsolute: absolute,
28
+ fenceRoot,
29
+ displayPath: (fileAbs) => displayPath(ctx, fileAbs),
30
+ ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
31
+ });
64
32
  return applyPostProcess(args, body);
65
33
  };
66
34
  export const grepTool = {
@@ -68,13 +36,13 @@ export const grepTool = {
68
36
  registration: {
69
37
  toolId: 'grep',
70
38
  name: 'grep',
71
- description: 'Regex search across project files (gitignore-aware by default; Node walk not ripgrep). Optional postProcess.',
39
+ description: 'Regex search across project files (gitignore-aware by default; ripgrep when available, else grep, else bounded Node walk). Optional postProcess.',
72
40
  inputSchema: {
73
41
  type: 'object',
74
42
  properties: {
75
43
  pattern: {
76
44
  type: 'string',
77
- description: 'JavaScript RegExp source',
45
+ description: 'Regex pattern (ripgrep dialect when rg is available; JavaScript RegExp on Node fallback)',
78
46
  },
79
47
  path: {
80
48
  type: 'string',
@@ -0,0 +1,15 @@
1
+ export type SpawnCaptureResult = {
2
+ readonly stdout: string;
3
+ readonly stderr: string;
4
+ readonly code: number | null;
5
+ };
6
+ export type SpawnCaptureOptions = {
7
+ readonly cwd?: string;
8
+ readonly signal?: AbortSignal;
9
+ readonly maxStdout?: number;
10
+ };
11
+ /**
12
+ * Async spawn with arg array (no shell). Honors AbortSignal via Node's
13
+ * spawn `signal` (kills the child). Caps captured stdout.
14
+ */
15
+ export declare const spawnCapture: (command: string, args: readonly string[], options?: SpawnCaptureOptions) => Promise<SpawnCaptureResult>;
@@ -0,0 +1,42 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Async spawn with arg array (no shell). Honors AbortSignal via Node's
4
+ * spawn `signal` (kills the child). Caps captured stdout.
5
+ */
6
+ export const spawnCapture = (command, args, options = {}) => {
7
+ const maxStdout = options.maxStdout ?? 2_000_000;
8
+ return new Promise((resolve, reject) => {
9
+ if (options.signal?.aborted) {
10
+ reject(new Error('aborted'));
11
+ return;
12
+ }
13
+ const child = spawn(command, [...args], {
14
+ cwd: options.cwd,
15
+ windowsHide: true,
16
+ shell: false,
17
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
18
+ });
19
+ let stdout = '';
20
+ let stderr = '';
21
+ child.stdout?.on('data', (chunk) => {
22
+ if (stdout.length < maxStdout) {
23
+ stdout += String(chunk);
24
+ if (stdout.length > maxStdout) {
25
+ stdout = stdout.slice(0, maxStdout);
26
+ }
27
+ }
28
+ });
29
+ child.stderr?.on('data', (chunk) => {
30
+ stderr += String(chunk);
31
+ if (stderr.length > 64_000) {
32
+ stderr = stderr.slice(0, 64_000);
33
+ }
34
+ });
35
+ child.on('error', (error) => {
36
+ reject(error);
37
+ });
38
+ child.on('close', (code) => {
39
+ resolve({ stdout, stderr, code });
40
+ });
41
+ });
42
+ };
@@ -6,6 +6,8 @@ export type HandlerContext = {
6
6
  /** Absolute (or project-relative) roots trusted outside the project. */
7
7
  readonly allowedRoots: readonly string[];
8
8
  readonly bashEnabled: boolean;
9
+ /** Per-invoke abort (tool timeout / run cancel). */
10
+ readonly signal?: AbortSignal;
9
11
  };
10
12
  export type BuiltinTool<Id extends string = string> = {
11
13
  readonly id: Id;
@@ -1,2 +1,16 @@
1
+ /** Always skip these directory names (ts-scan-style), even with --no-ignore. */
2
+ export declare const WALK_EXCLUDE_DIR_NAMES: readonly ["node_modules", "dist", "build", ".git"];
1
3
  export declare const globToRegExp: (pattern: string) => RegExp;
2
- export declare const walkFiles: (root: string, dir: string, respectGitignore: boolean) => Promise<readonly string[]>;
4
+ export type WalkFilesOptions = {
5
+ readonly respectGitignore?: boolean;
6
+ readonly signal?: AbortSignal;
7
+ /** Stop collecting after this many files (default unlimited). */
8
+ readonly maxFiles?: number;
9
+ /** Yield to the event loop every N directory entries (default 32). */
10
+ readonly yieldEvery?: number;
11
+ };
12
+ /**
13
+ * Async recursive file walk. Third arg may be `respectGitignore` boolean
14
+ * (legacy) or {@link WalkFilesOptions}.
15
+ */
16
+ export declare const walkFiles: (root: string, dir: string, options?: boolean | WalkFilesOptions) => Promise<readonly string[]>;
@@ -1,6 +1,13 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { loadGitIgnoreMatcher } from '../gitignore.js';
4
+ /** Always skip these directory names (ts-scan-style), even with --no-ignore. */
5
+ export const WALK_EXCLUDE_DIR_NAMES = [
6
+ 'node_modules',
7
+ 'dist',
8
+ 'build',
9
+ '.git',
10
+ ];
4
11
  export const globToRegExp = (pattern) => {
5
12
  const escaped = pattern
6
13
  .replace(/\\/g, '/')
@@ -11,12 +18,42 @@ export const globToRegExp = (pattern) => {
11
18
  .replace(/::DOUBLESTAR::/g, '.*');
12
19
  return new RegExp(`^${escaped}$`);
13
20
  };
14
- export const walkFiles = async (root, dir, respectGitignore) => {
21
+ const normalizeOptions = (options) => typeof options === 'boolean' ? { respectGitignore: options } : options;
22
+ const yieldEventLoop = () => new Promise((resolve) => {
23
+ setImmediate(resolve);
24
+ });
25
+ const throwIfAborted = (signal) => {
26
+ if (signal?.aborted) {
27
+ throw new Error('aborted');
28
+ }
29
+ };
30
+ /**
31
+ * Async recursive file walk. Third arg may be `respectGitignore` boolean
32
+ * (legacy) or {@link WalkFilesOptions}.
33
+ */
34
+ export const walkFiles = async (root, dir, options = true) => {
35
+ const opts = normalizeOptions(options);
36
+ const respectGitignore = opts.respectGitignore !== false;
37
+ const yieldEvery = opts.yieldEvery ?? 32;
15
38
  const matcher = respectGitignore
16
39
  ? await loadGitIgnoreMatcher(root)
17
40
  : { ignores: () => false };
18
41
  const out = [];
42
+ const visitedDirs = new Set();
43
+ let steps = 0;
19
44
  const visit = async (absoluteDir) => {
45
+ throwIfAborted(opts.signal);
46
+ let realDir;
47
+ try {
48
+ realDir = await fs.realpath(absoluteDir);
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ if (visitedDirs.has(realDir)) {
54
+ return;
55
+ }
56
+ visitedDirs.add(realDir);
20
57
  let entries;
21
58
  try {
22
59
  entries = await fs.readdir(absoluteDir, { withFileTypes: true });
@@ -25,6 +62,16 @@ export const walkFiles = async (root, dir, respectGitignore) => {
25
62
  return;
26
63
  }
27
64
  for (const entry of entries) {
65
+ throwIfAborted(opts.signal);
66
+ steps += 1;
67
+ if (steps % yieldEvery === 0) {
68
+ await yieldEventLoop();
69
+ throwIfAborted(opts.signal);
70
+ }
71
+ if (entry.isDirectory() &&
72
+ WALK_EXCLUDE_DIR_NAMES.includes(entry.name)) {
73
+ continue;
74
+ }
28
75
  const absolute = path.join(absoluteDir, entry.name);
29
76
  const relative = path
30
77
  .relative(root, absolute)
@@ -33,11 +80,18 @@ export const walkFiles = async (root, dir, respectGitignore) => {
33
80
  if (matcher.ignores(relative, entry.isDirectory())) {
34
81
  continue;
35
82
  }
83
+ if (entry.isSymbolicLink()) {
84
+ continue;
85
+ }
36
86
  if (entry.isDirectory()) {
37
87
  await visit(absolute);
38
88
  }
39
89
  else if (entry.isFile()) {
40
90
  out.push(relative);
91
+ if (opts.maxFiles !== undefined &&
92
+ out.length >= opts.maxFiles) {
93
+ return;
94
+ }
41
95
  }
42
96
  }
43
97
  };
@@ -48,7 +48,10 @@ export const createProjectHarness = (options) => {
48
48
  return deniedToolResult(toolId, detail);
49
49
  }
50
50
  try {
51
- const text = await invokeBuiltin(toolId, ctx, call.args);
51
+ const invokeCtx = call.signal === undefined
52
+ ? ctx
53
+ : { ...ctx, signal: call.signal };
54
+ const text = await invokeBuiltin(toolId, invokeCtx, call.args);
52
55
  return { ok: true, text };
53
56
  }
54
57
  catch (error) {
@@ -15,6 +15,8 @@ export type ToolHandlerContext = {
15
15
  readonly webFetch?: (request: WebFetchRequest) => Promise<WebFetchResult>;
16
16
  readonly denyPaths?: readonly string[];
17
17
  readonly allowedHosts?: readonly string[];
18
+ /** Per-invoke abort (tool timeout / run cancel) for builtins. */
19
+ readonly signal?: AbortSignal;
18
20
  };
19
21
  export type ToolHandler = (args: Readonly<Record<string, unknown>>, ctx: ToolHandlerContext) => Promise<string>;
20
22
  export type DomainToolConfig = {
@@ -12,6 +12,8 @@ export type ToolInvokeResult = {
12
12
  export type ToolInvokeCall = {
13
13
  readonly toolId: string;
14
14
  readonly args: Readonly<Record<string, unknown>>;
15
+ /** Per-invoke abort (tool timeout / run cancel). */
16
+ readonly signal?: AbortSignal;
15
17
  };
16
18
  export type BuiltinToolRegistration = {
17
19
  readonly toolId: string;