praxis-agent 0.57.0 → 0.58.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/README.md CHANGED
@@ -27,7 +27,7 @@ sessions, configuration, or compatibility directories.
27
27
 
28
28
  - macOS or Linux
29
29
  - Node.js 24 or newer
30
- - [`ripgrep`](https://github.com/BurntSushi/ripgrep) (`rg`) for the Grep tool
30
+ - [`ripgrep`](https://github.com/BurntSushi/ripgrep) (`rg`) for the Grep and Glob tools
31
31
  - an API key and model ID for an Anthropic, OpenAI-compatible, or OpenAI
32
32
  Responses provider (the stable setup), or the explicitly enabled experimental
33
33
  ChatGPT-backed Codex subscription integration
@@ -1,9 +1,37 @@
1
- export interface GlobFilesOptions {
1
+ import { type ProcessResult } from '../platform/bounded-process-runner.js';
2
+ export interface GlobSearchRequest {
2
3
  root: string;
3
4
  displayRoot: string;
4
5
  absoluteRoot: string;
5
6
  pattern: string;
6
7
  signal?: AbortSignal;
7
8
  }
8
- export declare function globFiles(options: GlobFilesOptions): Promise<string>;
9
+ export interface GlobSearchResult {
10
+ content: string;
11
+ isError: boolean;
12
+ }
13
+ export interface GlobSearch {
14
+ search(request: GlobSearchRequest): Promise<GlobSearchResult>;
15
+ }
16
+ export interface RipgrepGlobSearchOptions {
17
+ cwd: string;
18
+ timeoutMs: number;
19
+ environment?: Readonly<Record<string, string>>;
20
+ runner?: {
21
+ run(options: {
22
+ command: string;
23
+ args: readonly string[];
24
+ cwd?: string;
25
+ timeoutMs: number;
26
+ signal?: AbortSignal;
27
+ env?: Readonly<Record<string, string>>;
28
+ }): Promise<ProcessResult>;
29
+ };
30
+ }
31
+ export declare class RipgrepGlobSearch implements GlobSearch {
32
+ private readonly options;
33
+ private readonly runner;
34
+ constructor(options: RipgrepGlobSearchOptions);
35
+ search(request: GlobSearchRequest): Promise<GlobSearchResult>;
36
+ }
9
37
  //# sourceMappingURL=glob.d.ts.map
@@ -1,88 +1,97 @@
1
- import { lstat, opendir } from 'node:fs/promises';
2
1
  import { isAbsolute, join, resolve, sep } from 'node:path';
3
2
  import { minimatch } from 'minimatch';
3
+ import { BoundedProcessRunner, joinedProcessOutput, } from '../platform/bounded-process-runner.js';
4
4
  const MAX_RESULTS = 100;
5
+ const MAX_ENUMERATION_BYTES = 2 * 1024 * 1024;
6
+ function portable(value) {
7
+ return sep === '/' ? value : value.split(sep).join('/');
8
+ }
5
9
  function abortError() {
6
10
  return new DOMException('Tool execution aborted', 'AbortError');
7
11
  }
8
- function portablePath(path) {
9
- return sep === '/' ? path : path.split(sep).join('/');
10
- }
11
- function compareMatches(left, right) {
12
- return left.mtimeMs - right.mtimeMs || left.order - right.order;
13
- }
14
- function insertOldest(matches, candidate) {
15
- let low = 0;
16
- let high = matches.length;
17
- while (low < high) {
18
- const middle = Math.floor((low + high) / 2);
19
- const current = matches[middle];
20
- if (current && compareMatches(current, candidate) <= 0)
21
- low = middle + 1;
22
- else
23
- high = middle;
12
+ export class RipgrepGlobSearch {
13
+ options;
14
+ runner;
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.runner =
18
+ options.runner ??
19
+ new BoundedProcessRunner({
20
+ cwd: options.cwd,
21
+ maxOutputBytes: MAX_ENUMERATION_BYTES,
22
+ });
24
23
  }
25
- matches.splice(low, 0, candidate);
26
- if (matches.length > MAX_RESULTS)
27
- matches.pop();
28
- }
29
- export async function globFiles(options) {
30
- const pattern = portablePath(options.pattern);
31
- const absolutePattern = isAbsolute(options.pattern);
32
- const matchBase = !absolutePattern && !pattern.includes('/');
33
- const matches = [];
34
- const directories = [''];
35
- let matchCount = 0;
36
- let order = 0;
37
- while (directories.length > 0) {
38
- if (options.signal?.aborted)
24
+ async search(request) {
25
+ if (request.signal?.aborted)
39
26
  throw abortError();
40
- const relativeDirectory = directories.pop() ?? '';
41
- const directory = await opendir(join(options.root, relativeDirectory));
42
- for await (const entry of directory) {
43
- if (options.signal?.aborted)
44
- throw abortError();
45
- const relativePath = join(relativeDirectory, entry.name);
46
- if (entry.isDirectory()) {
47
- directories.push(relativePath);
48
- continue;
49
- }
50
- if (!entry.isFile())
51
- continue;
52
- const portableRelativePath = portablePath(relativePath);
53
- const absolutePath = portablePath(resolve(options.absoluteRoot, relativePath));
54
- if (pattern !== '' &&
55
- !minimatch(absolutePattern ? absolutePath : portableRelativePath, pattern, { dot: true, matchBase, noext: true })) {
56
- continue;
57
- }
58
- let metadata;
59
- try {
60
- metadata = await lstat(join(options.root, relativePath));
61
- }
62
- catch (error) {
63
- if (error.code === 'ENOENT')
64
- continue;
65
- throw error;
66
- }
67
- if (!metadata.isFile())
68
- continue;
69
- const displayPath = absolutePattern
70
- ? absolutePath
71
- : portablePath(join(options.displayRoot, relativePath));
72
- insertOldest(matches, {
73
- path: displayPath,
74
- mtimeMs: metadata.mtimeMs,
75
- order,
27
+ const hidden = this.options.environment?.CLAUDE_CODE_GLOB_HIDDEN !== 'false';
28
+ const noIgnore = this.options.environment?.CLAUDE_CODE_GLOB_NO_IGNORE !== 'false';
29
+ let result;
30
+ try {
31
+ result = await this.runner.run({
32
+ command: 'rg',
33
+ args: [
34
+ '--files',
35
+ '--null',
36
+ '--sort',
37
+ 'modified',
38
+ ...(hidden ? ['--hidden'] : []),
39
+ ...(noIgnore ? ['--no-ignore'] : []),
40
+ ],
41
+ cwd: request.root,
42
+ timeoutMs: this.options.timeoutMs,
43
+ ...(request.signal ? { signal: request.signal } : {}),
44
+ ...(this.options.environment ? { env: this.options.environment } : {}),
76
45
  });
77
- matchCount += 1;
78
- order += 1;
79
46
  }
47
+ catch (error) {
48
+ if (error instanceof Error && error.name === 'AbortError')
49
+ throw error;
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ throw new Error(`Glob enumeration failed: ${message}`);
52
+ }
53
+ if (result.timedOut)
54
+ return {
55
+ content: `Search timed out after ${this.options.timeoutMs}ms`,
56
+ isError: true,
57
+ };
58
+ if (result.truncated)
59
+ throw new Error('Glob enumeration failed: output truncated');
60
+ if (result.code !== 0) {
61
+ if (result.code === 1 && !result.stdout && !result.stderr)
62
+ return { content: 'No files found', isError: false };
63
+ const output = joinedProcessOutput(result);
64
+ throw new Error(`Glob enumeration failed with exit code ${result.code}${output ? `: ${output}` : ''}`);
65
+ }
66
+ const pattern = portable(request.pattern);
67
+ const absolutePattern = isAbsolute(request.pattern);
68
+ const matchBase = !absolutePattern && !pattern.includes('/');
69
+ const paths = result.stdout.split('\0').filter(Boolean);
70
+ const matched = paths.filter((path) => {
71
+ const relativePath = portable(path);
72
+ const absolutePath = portable(resolve(request.absoluteRoot, relativePath));
73
+ return (pattern === '' ||
74
+ minimatch(absolutePattern ? absolutePath : relativePath, pattern, {
75
+ dot: true,
76
+ matchBase,
77
+ noext: true,
78
+ }));
79
+ });
80
+ if (matched.length === 0)
81
+ return { content: 'No files found', isError: false };
82
+ const display = matched.slice(0, MAX_RESULTS).map((path) => {
83
+ const relativePath = portable(path);
84
+ if (absolutePattern)
85
+ return portable(resolve(request.absoluteRoot, relativePath));
86
+ return portable(join(request.displayRoot, relativePath));
87
+ });
88
+ const content = display.join('\n');
89
+ return {
90
+ content: matched.length > MAX_RESULTS
91
+ ? `${content}\n(Showing 100 of ${matched.length} matching files; ${matched.length - 100} more are not listed. Narrow the pattern or path to see the rest.)`
92
+ : content,
93
+ isError: false,
94
+ };
80
95
  }
81
- if (matchCount === 0)
82
- return 'No files found';
83
- const content = matches.map((match) => match.path).join('\n');
84
- return matchCount > MAX_RESULTS
85
- ? `${content}\n(Showing ${MAX_RESULTS} of ${matchCount} matching files; ${matchCount - MAX_RESULTS} more are not listed. Narrow the pattern or path to see the rest.)`
86
- : content;
87
96
  }
88
97
  //# sourceMappingURL=glob.js.map
@@ -1,4 +1,5 @@
1
1
  import type { ModelToolCall, ModelToolDefinition, ToolExecutionContext, ToolExecutionResult, ToolRegistry } from '../core/runtime.js';
2
+ import { type GlobSearch } from './glob.js';
2
3
  import type { DataPlane } from '../persistence/data-plane.js';
3
4
  export interface LocalToolRegistryOptions {
4
5
  cwd: string;
@@ -17,6 +18,7 @@ export interface LocalToolRegistryOptions {
17
18
  sandbox?: BashSandboxRuntime;
18
19
  homeDirectory?: string;
19
20
  configRoot?: string;
21
+ globSearch?: GlobSearch;
20
22
  }
21
23
  export interface BashSandboxRuntime {
22
24
  shouldUseSandbox(input: {
@@ -46,6 +48,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
46
48
  private readonly maxShellTimeoutMs;
47
49
  private readonly maxSearchTimeoutMs;
48
50
  private readonly processRunner;
51
+ private readonly globSearch;
49
52
  private readonly enableReportFindings;
50
53
  private readonly environment;
51
54
  private readonly sessionEnvironment;
@@ -55,6 +58,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
55
58
  private readonly sessionCwds;
56
59
  private readonly protectedWriteReason;
57
60
  private readonly mutationTargetExisted;
61
+ private readonly preparedGlobRoots;
58
62
  constructor(options: LocalToolRegistryOptions);
59
63
  private assertProtectedWritePath;
60
64
  private assertProtectedBashCommand;
@@ -7,7 +7,7 @@ import sharp from 'sharp';
7
7
  import { countTokens } from '@anthropic-ai/tokenizer';
8
8
  import { commandShell, commandShellArguments, } from '../platform/command-shell.js';
9
9
  import { BoundedProcessRunner, joinedProcessOutput, } from '../platform/bounded-process-runner.js';
10
- import { globFiles } from './glob.js';
10
+ import { RipgrepGlobSearch } from './glob.js';
11
11
  import { countLineChanges } from './line-changes.js';
12
12
  import { editNotebook, formatNotebookForRead } from './notebook.js';
13
13
  import { openPdf } from './pdf.js';
@@ -537,6 +537,7 @@ export class LocalToolRegistry {
537
537
  maxShellTimeoutMs;
538
538
  maxSearchTimeoutMs;
539
539
  processRunner;
540
+ globSearch;
540
541
  enableReportFindings;
541
542
  environment;
542
543
  sessionEnvironment;
@@ -546,6 +547,7 @@ export class LocalToolRegistry {
546
547
  sessionCwds = new Map();
547
548
  protectedWriteReason;
548
549
  mutationTargetExisted = new WeakMap();
550
+ preparedGlobRoots = new WeakMap();
549
551
  constructor(options) {
550
552
  this.cwd = resolve(options.cwd);
551
553
  this.cwdProvider = options.cwdProvider;
@@ -576,6 +578,13 @@ export class LocalToolRegistry {
576
578
  cwd: this.cwd,
577
579
  maxOutputBytes: this.maxOutputBytes,
578
580
  });
581
+ this.globSearch =
582
+ options.globSearch ??
583
+ new RipgrepGlobSearch({
584
+ cwd: this.cwd,
585
+ timeoutMs: this.maxSearchTimeoutMs,
586
+ ...(this.environment ? { environment: this.environment } : {}),
587
+ });
579
588
  }
580
589
  assertProtectedWritePath(filePath) {
581
590
  const reason = this.protectedWriteReason(filePath);
@@ -805,14 +814,16 @@ export class LocalToolRegistry {
805
814
  case 'Glob': {
806
815
  const pathInput = call.input.path;
807
816
  const requestedPath = pathInput === undefined ? '.' : stringInput(call.input, 'path');
808
- await this.globRoot(requestedPath, context);
809
- return {
817
+ const root = await this.globRoot(requestedPath, context);
818
+ const prepared = {
810
819
  ...call,
811
820
  input: {
812
821
  pattern: stringInput(call.input, 'pattern', true),
813
822
  ...(pathInput === undefined ? {} : { path: requestedPath }),
814
823
  },
815
824
  };
825
+ this.preparedGlobRoots.set(prepared, root);
826
+ return prepared;
816
827
  }
817
828
  case 'Grep': {
818
829
  const glob = optionalString(call.input, 'glob');
@@ -857,6 +868,11 @@ export class LocalToolRegistry {
857
868
  if (JSON.stringify(prepared.input) !== JSON.stringify(call.input)) {
858
869
  throw new Error('Tool input changed after permission approval');
859
870
  }
871
+ const approvedGlobRoot = this.preparedGlobRoots.get(call);
872
+ if (approvedGlobRoot !== undefined &&
873
+ this.preparedGlobRoots.get(prepared) !== approvedGlobRoot) {
874
+ throw new Error('Tool input changed after permission approval');
875
+ }
860
876
  const executionTargetExisted = this.mutationTargetExisted.get(prepared);
861
877
  if (approvedTargetExisted !== undefined &&
862
878
  executionTargetExisted !== approvedTargetExisted) {
@@ -1466,37 +1482,21 @@ export class LocalToolRegistry {
1466
1482
  }
1467
1483
  async glob(call, context) {
1468
1484
  const requestedPath = call.input.path === undefined ? '.' : stringInput(call.input, 'path');
1469
- const root = await this.globRoot(requestedPath, context);
1470
- const timeoutSignal = AbortSignal.timeout(this.maxSearchTimeoutMs);
1471
- const searchSignal = context.signal
1472
- ? AbortSignal.any([context.signal, timeoutSignal])
1473
- : timeoutSignal;
1474
- try {
1475
- const content = await globFiles({
1476
- root,
1477
- displayRoot: call.input.path === undefined ? '.' : requestedPath,
1478
- absoluteRoot: isAbsolute(requestedPath)
1479
- ? resolve(requestedPath)
1480
- : resolve(this.currentCwd(context), requestedPath),
1481
- pattern: stringInput(call.input, 'pattern', true),
1482
- signal: searchSignal,
1483
- });
1484
- return {
1485
- content: truncateOutput(content, this.maxOutputBytes),
1486
- isError: false,
1487
- };
1488
- }
1489
- catch (error) {
1490
- if (context.signal?.aborted)
1491
- throw abortError();
1492
- if (timeoutSignal.aborted) {
1493
- return {
1494
- content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1495
- isError: true,
1496
- };
1497
- }
1498
- throw error;
1499
- }
1485
+ const root = this.preparedGlobRoots.get(call) ??
1486
+ (await this.globRoot(requestedPath, context));
1487
+ const result = await this.globSearch.search({
1488
+ root,
1489
+ displayRoot: call.input.path === undefined ? '.' : requestedPath,
1490
+ absoluteRoot: isAbsolute(requestedPath)
1491
+ ? resolve(requestedPath)
1492
+ : resolve(this.currentCwd(context), requestedPath),
1493
+ pattern: stringInput(call.input, 'pattern', true),
1494
+ ...(context.signal ? { signal: context.signal } : {}),
1495
+ });
1496
+ return {
1497
+ content: truncateOutput(result.content, this.maxOutputBytes),
1498
+ isError: result.isError,
1499
+ };
1500
1500
  }
1501
1501
  async grep(call, context) {
1502
1502
  const workspaceRoot = await realpath(this.currentCwd(context));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -62,7 +62,7 @@
62
62
  "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
63
63
  "verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
64
64
  "test:fixtures": "node scripts/run-fixture-contracts.mjs",
65
- "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts src/evals/lsp-diagnostics-admission.test.ts"
65
+ "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts src/evals/lsp-diagnostics-admission.test.ts src/evals/glob-ripgrep-admission.test.ts"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=24"