@mikode13/harness 2.2.0 → 3.0.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.
Files changed (57) hide show
  1. package/README.md +6 -3
  2. package/dist/engines/domain/model/llmAgent.d.ts +1 -1
  3. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  4. package/dist/factory/infrastructure/agentFactory.d.ts +15 -46
  5. package/dist/factory/infrastructure/agentFactory.js +46 -77
  6. package/dist/factory/infrastructure/agentFactory.js.map +1 -1
  7. package/dist/factory/infrastructure/agentLLMFactory.d.ts +44 -0
  8. package/dist/factory/infrastructure/agentLLMFactory.js +98 -0
  9. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -0
  10. package/dist/factory/infrastructure/types.d.ts +33 -0
  11. package/dist/factory/infrastructure/types.js +3 -0
  12. package/dist/factory/infrastructure/types.js.map +1 -0
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/llm/domain/tool.d.ts +1 -8
  17. package/dist/llm/domain/tool.js.map +1 -1
  18. package/dist/llm/infrastructure/claudeLLMClient.d.ts +6 -2
  19. package/dist/llm/infrastructure/claudeLLMClient.js +26 -5
  20. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  21. package/dist/llm/infrastructure/openAILLMClient.d.ts +5 -1
  22. package/dist/llm/infrastructure/openAILLMClient.js +17 -3
  23. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  24. package/dist/orchestration/domain/model/instructedAgent.d.ts +15 -0
  25. package/dist/orchestration/domain/model/instructedAgent.js +17 -0
  26. package/dist/orchestration/domain/model/instructedAgent.js.map +1 -0
  27. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +3 -0
  28. package/dist/orchestration/domain/model/orchestratorAgent.js +9 -9
  29. package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
  30. package/dist/tools/domain/tool.d.ts +9 -0
  31. package/dist/tools/domain/tool.js +2 -0
  32. package/dist/tools/domain/tool.js.map +1 -0
  33. package/dist/tools/domain/workspace.d.ts +48 -0
  34. package/dist/tools/domain/workspace.js +2 -0
  35. package/dist/tools/domain/workspace.js.map +1 -0
  36. package/dist/tools/infrastructure/boundedWorkspace.d.ts +66 -0
  37. package/dist/tools/infrastructure/boundedWorkspace.js +160 -0
  38. package/dist/tools/infrastructure/boundedWorkspace.js.map +1 -0
  39. package/dist/tools/infrastructure/createWorkspace.d.ts +14 -0
  40. package/dist/tools/infrastructure/createWorkspace.js +54 -0
  41. package/dist/tools/infrastructure/createWorkspace.js.map +1 -0
  42. package/dist/tools/infrastructure/defineTool.d.ts +15 -0
  43. package/dist/tools/infrastructure/defineTool.js +87 -0
  44. package/dist/tools/infrastructure/defineTool.js.map +1 -0
  45. package/dist/tools/infrastructure/gitWorkspace.d.ts +13 -0
  46. package/dist/tools/infrastructure/gitWorkspace.js +61 -0
  47. package/dist/tools/infrastructure/gitWorkspace.js.map +1 -0
  48. package/dist/tools/infrastructure/ripgrepWorkspace.d.ts +20 -0
  49. package/dist/tools/infrastructure/ripgrepWorkspace.js +85 -0
  50. package/dist/tools/infrastructure/ripgrepWorkspace.js.map +1 -0
  51. package/dist/tools/infrastructure/runProcess.d.ts +23 -0
  52. package/dist/tools/infrastructure/runProcess.js +91 -0
  53. package/dist/tools/infrastructure/runProcess.js.map +1 -0
  54. package/dist/tools/infrastructure/workspaceTools.d.ts +4 -0
  55. package/dist/tools/infrastructure/workspaceTools.js +110 -0
  56. package/dist/tools/infrastructure/workspaceTools.js.map +1 -0
  57. package/package.json +2 -1
@@ -0,0 +1,13 @@
1
+ import type { TextMatch } from '../domain/workspace.ts';
2
+ import { BoundedWorkspace } from './boundedWorkspace.ts';
3
+ /**
4
+ * The fallback `Workspace`, on the git the repository already has. git applies `.gitignore`
5
+ * by itself, even to an explicit path, but only inside a repository: outside one, every
6
+ * operation fails.
7
+ */
8
+ export declare class GitWorkspace extends BoundedWorkspace {
9
+ protected listCandidates(signal: AbortSignal): Promise<string[]>;
10
+ protected findMatches(pattern: string, ignoreCase: boolean, signal: AbortSignal, keep: (match: TextMatch) => void): Promise<void>;
11
+ private listFiles0;
12
+ private git;
13
+ }
@@ -0,0 +1,61 @@
1
+ import { BoundedWorkspace } from "./boundedWorkspace.js";
2
+ import { firstLine, runProcess } from "./runProcess.js";
3
+ /**
4
+ * The fallback `Workspace`, on the git the repository already has. git applies `.gitignore`
5
+ * by itself, even to an explicit path, but only inside a repository: outside one, every
6
+ * operation fails.
7
+ */
8
+ export class GitWorkspace extends BoundedWorkspace {
9
+ async listCandidates(signal) {
10
+ // Tracked files and new ones not yet added; then the tracked ones `.gitignore` matches
11
+ // anyway, such as a `.env` committed before it was ignored. ripgrep hides those, and the
12
+ // fallback must not show what the preferred implementation hides.
13
+ const [listed, ignored] = await Promise.all([
14
+ this.listFiles0(['--cached', '--others', '--exclude-standard'], signal),
15
+ this.listFiles0(['--cached', '--ignored', '--exclude-standard'], signal),
16
+ ]);
17
+ const hidden = new Set(ignored);
18
+ return listed.filter(path => !hidden.has(path));
19
+ }
20
+ async findMatches(pattern, ignoreCase, signal, keep) {
21
+ const { stderr, exitCode } = await this.git([
22
+ 'grep',
23
+ // Untracked files too, or the agent could not search a file it has just created.
24
+ '--untracked',
25
+ // Binary files left out, as ripgrep does.
26
+ '-I',
27
+ '--line-number',
28
+ // path NUL line NUL text, so a colon in a path cannot split it.
29
+ '-z',
30
+ // Perl syntax, so `\d` and `\b` mean what they mean to ripgrep.
31
+ '-P',
32
+ ...(ignoreCase ? ['--ignore-case'] : []),
33
+ // `-e`, so a pattern starting with a dash is never read as an option.
34
+ '-e',
35
+ pattern,
36
+ ], signal, line => {
37
+ const [path, number, ...text] = line.split('\0');
38
+ if (path && number) {
39
+ keep({ path, line: Number(number), text: text.join('\0').replace(/\r$/, '') });
40
+ }
41
+ });
42
+ // 1 means no match; anything above is a failure, such as a pattern that does not compile.
43
+ if (exitCode > 1) {
44
+ throw new Error(`git could not search for the pattern: ${firstLine(stderr)}`);
45
+ }
46
+ }
47
+ async listFiles0(options, signal) {
48
+ const { stdout, stderr, exitCode } = await this.git(['ls-files', '-z', ...options], signal);
49
+ if (exitCode !== 0) {
50
+ throw new Error(`git could not list the files: ${firstLine(stderr)}`);
51
+ }
52
+ return stdout.split('\0').filter(path => path !== '');
53
+ }
54
+ git(args, signal, onLine) {
55
+ // A user's settings must not change the output this parses: colour codes would end up in
56
+ // the text, `grep.column` adds a field, and `grep.fullName` makes paths relative to the
57
+ // repository instead of the root.
58
+ return runProcess('git', ['-c', 'color.ui=never', '-c', 'grep.column=false', '-c', 'grep.fullName=false', ...args], { cwd: this.root, signal, onLine });
59
+ }
60
+ }
61
+ //# sourceMappingURL=gitWorkspace.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gitWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/gitWorkspace.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAExD;;;;GAIG;AACH,MAAM,OAAO,YAAa,SAAQ,gBAAgB;IACvC,KAAK,CAAC,cAAc,CAAC,MAAmB;QACjD,uFAAuF;QACvF,yFAAyF;QACzF,kEAAkE;QAClE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YAC3C,IAAI,CAAC,UAAU,CAAC,CAAC,UAAU,EAAE,UAAU,EAAE,oBAAoB,CAAC,EAAE,MAAM,CAAC;YACvE,IAAI,CAAC,UAAU,CAAC,CAAC,UAAU,EAAE,WAAW,EAAE,oBAAoB,CAAC,EAAE,MAAM,CAAC;SACxE,CAAC,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC;QAEhC,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;IACjD,CAAC;IAES,KAAK,CAAC,WAAW,CAC1B,OAAe,EACf,UAAmB,EACnB,MAAmB,EACnB,IAAgC;QAEhC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,IAAI,CAAC,GAAG,CAC1C;YACC,MAAM;YACN,iFAAiF;YACjF,aAAa;YACb,0CAA0C;YAC1C,IAAI;YACJ,eAAe;YACf,gEAAgE;YAChE,IAAI;YACJ,gEAAgE;YAChE,IAAI;YACJ,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACxC,sEAAsE;YACtE,IAAI;YACJ,OAAO;SACP,EACD,MAAM,EACN,IAAI,CAAC,EAAE;YACN,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACjD,IAAI,IAAI,IAAI,MAAM,EAAE,CAAC;gBACpB,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC;YAChF,CAAC;QACF,CAAC,CACD,CAAC;QACF,0FAA0F;QAC1F,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,yCAAyC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC/E,CAAC;IACF,CAAC;IAEO,KAAK,CAAC,UAAU,CAAC,OAAiB,EAAE,MAAmB;QAC9D,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,CAAC,UAAU,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,EAAE,MAAM,CAAC,CAAC;QAC5F,IAAI,QAAQ,KAAK,CAAC,EAAE,CAAC;YACpB,MAAM,IAAI,KAAK,CAAC,iCAAiC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACvE,CAAC;QAED,OAAO,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC;IACvD,CAAC;IAEO,GAAG,CAAC,IAAc,EAAE,MAAmB,EAAE,MAA+B;QAC/E,yFAAyF;QACzF,wFAAwF;QACxF,kCAAkC;QAClC,OAAO,UAAU,CAChB,KAAK,EACL,CAAC,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,mBAAmB,EAAE,IAAI,EAAE,qBAAqB,EAAE,GAAG,IAAI,CAAC,EACzF,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,CAClC,CAAC;IACH,CAAC;CACD","sourcesContent":["import type { TextMatch } from '../domain/workspace.ts';\nimport { BoundedWorkspace } from './boundedWorkspace.ts';\nimport { firstLine, runProcess } from './runProcess.ts';\n\n/**\n * The fallback `Workspace`, on the git the repository already has. git applies `.gitignore`\n * by itself, even to an explicit path, but only inside a repository: outside one, every\n * operation fails.\n */\nexport class GitWorkspace extends BoundedWorkspace {\n\tprotected async listCandidates(signal: AbortSignal): Promise<string[]> {\n\t\t// Tracked files and new ones not yet added; then the tracked ones `.gitignore` matches\n\t\t// anyway, such as a `.env` committed before it was ignored. ripgrep hides those, and the\n\t\t// fallback must not show what the preferred implementation hides.\n\t\tconst [listed, ignored] = await Promise.all([\n\t\t\tthis.listFiles0(['--cached', '--others', '--exclude-standard'], signal),\n\t\t\tthis.listFiles0(['--cached', '--ignored', '--exclude-standard'], signal),\n\t\t]);\n\t\tconst hidden = new Set(ignored);\n\n\t\treturn listed.filter(path => !hidden.has(path));\n\t}\n\n\tprotected async findMatches(\n\t\tpattern: string,\n\t\tignoreCase: boolean,\n\t\tsignal: AbortSignal,\n\t\tkeep: (match: TextMatch) => void,\n\t): Promise<void> {\n\t\tconst { stderr, exitCode } = await this.git(\n\t\t\t[\n\t\t\t\t'grep',\n\t\t\t\t// Untracked files too, or the agent could not search a file it has just created.\n\t\t\t\t'--untracked',\n\t\t\t\t// Binary files left out, as ripgrep does.\n\t\t\t\t'-I',\n\t\t\t\t'--line-number',\n\t\t\t\t// path NUL line NUL text, so a colon in a path cannot split it.\n\t\t\t\t'-z',\n\t\t\t\t// Perl syntax, so `\\d` and `\\b` mean what they mean to ripgrep.\n\t\t\t\t'-P',\n\t\t\t\t...(ignoreCase ? ['--ignore-case'] : []),\n\t\t\t\t// `-e`, so a pattern starting with a dash is never read as an option.\n\t\t\t\t'-e',\n\t\t\t\tpattern,\n\t\t\t],\n\t\t\tsignal,\n\t\t\tline => {\n\t\t\t\tconst [path, number, ...text] = line.split('\\0');\n\t\t\t\tif (path && number) {\n\t\t\t\t\tkeep({ path, line: Number(number), text: text.join('\\0').replace(/\\r$/, '') });\n\t\t\t\t}\n\t\t\t},\n\t\t);\n\t\t// 1 means no match; anything above is a failure, such as a pattern that does not compile.\n\t\tif (exitCode > 1) {\n\t\t\tthrow new Error(`git could not search for the pattern: ${firstLine(stderr)}`);\n\t\t}\n\t}\n\n\tprivate async listFiles0(options: string[], signal: AbortSignal): Promise<string[]> {\n\t\tconst { stdout, stderr, exitCode } = await this.git(['ls-files', '-z', ...options], signal);\n\t\tif (exitCode !== 0) {\n\t\t\tthrow new Error(`git could not list the files: ${firstLine(stderr)}`);\n\t\t}\n\n\t\treturn stdout.split('\\0').filter(path => path !== '');\n\t}\n\n\tprivate git(args: string[], signal: AbortSignal, onLine?: (line: string) => void) {\n\t\t// A user's settings must not change the output this parses: colour codes would end up in\n\t\t// the text, `grep.column` adds a field, and `grep.fullName` makes paths relative to the\n\t\t// repository instead of the root.\n\t\treturn runProcess(\n\t\t\t'git',\n\t\t\t['-c', 'color.ui=never', '-c', 'grep.column=false', '-c', 'grep.fullName=false', ...args],\n\t\t\t{ cwd: this.root, signal, onLine },\n\t\t);\n\t}\n}\n"]}
@@ -0,0 +1,20 @@
1
+ import type { TextMatch } from '../domain/workspace.ts';
2
+ import { BoundedWorkspace } from './boundedWorkspace.ts';
3
+ /**
4
+ * The preferred `Workspace`, on the ripgrep binary that ships with the package. It always runs
5
+ * from the root with no path: ripgrep ignores nothing that it is given explicitly.
6
+ *
7
+ * It takes the binary's path instead of importing `@vscode/ripgrep`, which throws on import when
8
+ * the platform has no binary; `createWorkspace` loads it where that failure can be handled.
9
+ */
10
+ export declare class RipgrepWorkspace extends BoundedWorkspace {
11
+ private readonly ripgrepPath;
12
+ constructor({ ripgrepPath, ...options }: {
13
+ root: string;
14
+ ripgrepPath: string;
15
+ timeoutMs?: number;
16
+ });
17
+ protected listCandidates(signal: AbortSignal): Promise<string[]>;
18
+ protected findMatches(pattern: string, ignoreCase: boolean, signal: AbortSignal, keep: (match: TextMatch) => void): Promise<void>;
19
+ private ripgrep;
20
+ }
@@ -0,0 +1,85 @@
1
+ import { BoundedWorkspace } from "./boundedWorkspace.js";
2
+ import { firstLine, runProcess } from "./runProcess.js";
3
+ // Every call sees what git would. Each flag is ours; nothing a model writes ever reaches the
4
+ // command line except the pattern, and that only after `-e`.
5
+ const sameViewAsGit = [
6
+ // A user's ripgrep configuration could switch the ignore rules off.
7
+ '--no-config',
8
+ // ripgrep's own `.ignore` and `.rgignore` outrank `.gitignore`, so a `!.env` line in either
9
+ // would show a secret git hides. Only git's ignore sources count.
10
+ '--no-ignore-dot',
11
+ // Hidden files such as `.github/` are part of the repository; ripgrep skips them by default.
12
+ '--hidden',
13
+ // `.gitignore` applies even outside a git repository.
14
+ '--no-require-git',
15
+ // `--hidden` would otherwise search git's own folder.
16
+ '--glob',
17
+ '!.git',
18
+ // Windows would print `\`; every path in a `Workspace` is written with `/`.
19
+ '--path-separator',
20
+ '/',
21
+ ];
22
+ function decode({ text, bytes }) {
23
+ return text ?? Buffer.from(bytes ?? '', 'base64').toString('utf8');
24
+ }
25
+ /**
26
+ * The preferred `Workspace`, on the ripgrep binary that ships with the package. It always runs
27
+ * from the root with no path: ripgrep ignores nothing that it is given explicitly.
28
+ *
29
+ * It takes the binary's path instead of importing `@vscode/ripgrep`, which throws on import when
30
+ * the platform has no binary; `createWorkspace` loads it where that failure can be handled.
31
+ */
32
+ export class RipgrepWorkspace extends BoundedWorkspace {
33
+ ripgrepPath;
34
+ constructor({ ripgrepPath, ...options }) {
35
+ super(options);
36
+ this.ripgrepPath = ripgrepPath;
37
+ }
38
+ async listCandidates(signal) {
39
+ const { stdout, stderr, exitCode } = await this.ripgrep(['--files', '--null'], signal);
40
+ // 1 means nothing to list, which an empty repository is. 2 with a listing means some
41
+ // folder could not be read, which git skips with a warning too; without one, it failed.
42
+ if (exitCode > 1 && stdout === '') {
43
+ throw new Error(`ripgrep could not list the files: ${firstLine(stderr)}`);
44
+ }
45
+ return stdout
46
+ .split('\0')
47
+ .filter(path => path !== '')
48
+ .map(path => path.replace(/^\.\//, ''));
49
+ }
50
+ async findMatches(pattern, ignoreCase, signal, keep) {
51
+ // ripgrep ends every search it ran with a summary. A failure before searching, such as a
52
+ // pattern that does not compile, prints none. A count, not a flag: TypeScript cannot see
53
+ // the callback change it.
54
+ let summaries = 0;
55
+ const { stderr, exitCode } = await this.ripgrep(['--json', ...(ignoreCase ? ['--ignore-case'] : []), '-e', pattern], signal, line => {
56
+ if (line === '') {
57
+ return;
58
+ }
59
+ const event = JSON.parse(line);
60
+ if (event.type === 'summary') {
61
+ summaries++;
62
+ }
63
+ if (event.type === 'match') {
64
+ keep({
65
+ path: decode(event.data.path).replace(/^\.\//, ''),
66
+ line: event.data.line_number,
67
+ text: decode(event.data.lines).replace(/\r?\n$/, ''),
68
+ });
69
+ }
70
+ });
71
+ // 1 means no match. 2 after a search means some file could not be read, and the rest
72
+ // stands, as it does on git; 2 with no search is the failure.
73
+ if (exitCode > 1 && summaries === 0) {
74
+ throw new Error(`ripgrep could not search for the pattern: ${firstLine(stderr)}`);
75
+ }
76
+ }
77
+ ripgrep(args, signal, onLine) {
78
+ return runProcess(this.ripgrepPath, [...sameViewAsGit, ...args], {
79
+ cwd: this.root,
80
+ signal,
81
+ onLine,
82
+ });
83
+ }
84
+ }
85
+ //# sourceMappingURL=ripgrepWorkspace.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ripgrepWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/ripgrepWorkspace.ts"],"names":[],"mappings":"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","sourcesContent":["import type { TextMatch } from '../domain/workspace.ts';\nimport { BoundedWorkspace } from './boundedWorkspace.ts';\nimport { firstLine, runProcess } from './runProcess.ts';\n\n// Every call sees what git would. Each flag is ours; nothing a model writes ever reaches the\n// command line except the pattern, and that only after `-e`.\nconst sameViewAsGit = [\n\t// A user's ripgrep configuration could switch the ignore rules off.\n\t'--no-config',\n\t// ripgrep's own `.ignore` and `.rgignore` outrank `.gitignore`, so a `!.env` line in either\n\t// would show a secret git hides. Only git's ignore sources count.\n\t'--no-ignore-dot',\n\t// Hidden files such as `.github/` are part of the repository; ripgrep skips them by default.\n\t'--hidden',\n\t// `.gitignore` applies even outside a git repository.\n\t'--no-require-git',\n\t// `--hidden` would otherwise search git's own folder.\n\t'--glob',\n\t'!.git',\n\t// Windows would print `\\`; every path in a `Workspace` is written with `/`.\n\t'--path-separator',\n\t'/',\n];\n\ninterface RipgrepText {\n\ttext?: string;\n\t// ripgrep sends what is not valid UTF-8 as base64 instead.\n\tbytes?: string;\n}\n\ninterface RipgrepEvent {\n\ttype: string;\n\tdata: { path: RipgrepText; line_number: number; lines: RipgrepText };\n}\n\nfunction decode({ text, bytes }: RipgrepText): string {\n\treturn text ?? Buffer.from(bytes ?? '', 'base64').toString('utf8');\n}\n\n/**\n * The preferred `Workspace`, on the ripgrep binary that ships with the package. It always runs\n * from the root with no path: ripgrep ignores nothing that it is given explicitly.\n *\n * It takes the binary's path instead of importing `@vscode/ripgrep`, which throws on import when\n * the platform has no binary; `createWorkspace` loads it where that failure can be handled.\n */\nexport class RipgrepWorkspace extends BoundedWorkspace {\n\tprivate readonly ripgrepPath: string;\n\n\tconstructor({\n\t\tripgrepPath,\n\t\t...options\n\t}: {\n\t\troot: string;\n\t\tripgrepPath: string;\n\t\ttimeoutMs?: number;\n\t}) {\n\t\tsuper(options);\n\t\tthis.ripgrepPath = ripgrepPath;\n\t}\n\n\tprotected async listCandidates(signal: AbortSignal): Promise<string[]> {\n\t\tconst { stdout, stderr, exitCode } = await this.ripgrep(['--files', '--null'], signal);\n\t\t// 1 means nothing to list, which an empty repository is. 2 with a listing means some\n\t\t// folder could not be read, which git skips with a warning too; without one, it failed.\n\t\tif (exitCode > 1 && stdout === '') {\n\t\t\tthrow new Error(`ripgrep could not list the files: ${firstLine(stderr)}`);\n\t\t}\n\n\t\treturn stdout\n\t\t\t.split('\\0')\n\t\t\t.filter(path => path !== '')\n\t\t\t.map(path => path.replace(/^\\.\\//, ''));\n\t}\n\n\tprotected async findMatches(\n\t\tpattern: string,\n\t\tignoreCase: boolean,\n\t\tsignal: AbortSignal,\n\t\tkeep: (match: TextMatch) => void,\n\t): Promise<void> {\n\t\t// ripgrep ends every search it ran with a summary. A failure before searching, such as a\n\t\t// pattern that does not compile, prints none. A count, not a flag: TypeScript cannot see\n\t\t// the callback change it.\n\t\tlet summaries = 0;\n\t\tconst { stderr, exitCode } = await this.ripgrep(\n\t\t\t['--json', ...(ignoreCase ? ['--ignore-case'] : []), '-e', pattern],\n\t\t\tsignal,\n\t\t\tline => {\n\t\t\t\tif (line === '') {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst event = JSON.parse(line) as RipgrepEvent;\n\t\t\t\tif (event.type === 'summary') {\n\t\t\t\t\tsummaries++;\n\t\t\t\t}\n\t\t\t\tif (event.type === 'match') {\n\t\t\t\t\tkeep({\n\t\t\t\t\t\tpath: decode(event.data.path).replace(/^\\.\\//, ''),\n\t\t\t\t\t\tline: event.data.line_number,\n\t\t\t\t\t\ttext: decode(event.data.lines).replace(/\\r?\\n$/, ''),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t},\n\t\t);\n\n\t\t// 1 means no match. 2 after a search means some file could not be read, and the rest\n\t\t// stands, as it does on git; 2 with no search is the failure.\n\t\tif (exitCode > 1 && summaries === 0) {\n\t\t\tthrow new Error(`ripgrep could not search for the pattern: ${firstLine(stderr)}`);\n\t\t}\n\t}\n\n\tprivate ripgrep(args: string[], signal: AbortSignal, onLine?: (line: string) => void) {\n\t\treturn runProcess(this.ripgrepPath, [...sameViewAsGit, ...args], {\n\t\t\tcwd: this.root,\n\t\t\tsignal,\n\t\t\tonLine,\n\t\t});\n\t}\n}\n"]}
@@ -0,0 +1,23 @@
1
+ export interface ProcessResult {
2
+ /** Empty when the output went to `onLine` instead. */
3
+ stdout: string;
4
+ stderr: string;
5
+ exitCode: number;
6
+ }
7
+ /**
8
+ * Runs a program with its arguments as an array, never through a shell, so nothing a model
9
+ * writes can become a command. Standard input is closed: ripgrep given no path searches its
10
+ * standard input whenever that is a pipe. A cancelled signal kills the process and rejects
11
+ * with the signal's `AbortError`.
12
+ *
13
+ * With `onLine`, each line of the output is handed over as it arrives and nothing is kept, so
14
+ * the caller decides what to hold; only a single line is then bound by the size limit. An
15
+ * error thrown by `onLine` stops the program and rejects with that error.
16
+ */
17
+ export declare function runProcess(command: string, args: string[], { cwd, signal, onLine }: {
18
+ cwd: string;
19
+ signal: AbortSignal;
20
+ onLine?: (line: string) => void;
21
+ }): Promise<ProcessResult>;
22
+ /** The first line of a program's error output, which is the part a model can act on. */
23
+ export declare function firstLine(text: string): string;
@@ -0,0 +1,91 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ // What a program may print, or one line of it may hold, before it is stopped. Beyond this it
4
+ // has printed far more than anyone can read.
5
+ const maxOutputBytes = 64 * 1024 * 1024;
6
+ /**
7
+ * Runs a program with its arguments as an array, never through a shell, so nothing a model
8
+ * writes can become a command. Standard input is closed: ripgrep given no path searches its
9
+ * standard input whenever that is a pipe. A cancelled signal kills the process and rejects
10
+ * with the signal's `AbortError`.
11
+ *
12
+ * With `onLine`, each line of the output is handed over as it arrives and nothing is kept, so
13
+ * the caller decides what to hold; only a single line is then bound by the size limit. An
14
+ * error thrown by `onLine` stops the program and rejects with that error.
15
+ */
16
+ export function runProcess(command, args, { cwd, signal, onLine }) {
17
+ return new Promise((resolve, reject) => {
18
+ const child = spawn(command, args, { cwd, signal, stdio: ['ignore', 'pipe', 'pipe'] });
19
+ const decoder = new StringDecoder('utf8');
20
+ const stdout = [];
21
+ const stderr = [];
22
+ let pending = '';
23
+ let size = 0;
24
+ let failure;
25
+ const stop = (error) => {
26
+ failure ??= error;
27
+ child.kill();
28
+ };
29
+ const deliver = (text) => {
30
+ if (!onLine) {
31
+ stdout.push(text);
32
+ return;
33
+ }
34
+ const lines = (pending + text).split('\n');
35
+ pending = lines.pop() ?? '';
36
+ for (const line of lines)
37
+ onLine(line);
38
+ };
39
+ child.stdout.on('data', (chunk) => {
40
+ if (failure) {
41
+ return;
42
+ }
43
+ size = onLine ? pending.length + chunk.length : size + chunk.length;
44
+ if (size > maxOutputBytes) {
45
+ stop(new Error(`The program printed more than ${String(maxOutputBytes / 1024 / 1024)} MB`));
46
+ return;
47
+ }
48
+ try {
49
+ deliver(decoder.write(chunk));
50
+ }
51
+ catch (error) {
52
+ stop(error instanceof Error ? error : new Error(String(error)));
53
+ }
54
+ });
55
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
56
+ // A missing program, or the cancellation, which arrives as an `AbortError`.
57
+ child.on('error', reject);
58
+ child.on('close', (code, killedBy) => {
59
+ // Stopped from outside, such as by the out-of-memory killer: what it printed is not the
60
+ // whole answer, and an exit code would pass it off as one. Its own stops set `failure`.
61
+ if (code === null && !failure) {
62
+ failure = new Error(`${command} was stopped by ${killedBy ?? 'a signal'} before it finished`);
63
+ }
64
+ if (!failure) {
65
+ try {
66
+ deliver(decoder.end());
67
+ if (onLine && pending !== '')
68
+ onLine(pending);
69
+ }
70
+ catch (error) {
71
+ failure = error instanceof Error ? error : new Error(String(error));
72
+ }
73
+ }
74
+ if (failure) {
75
+ reject(failure);
76
+ return;
77
+ }
78
+ resolve({
79
+ stdout: stdout.join(''),
80
+ stderr: Buffer.concat(stderr).toString('utf8'),
81
+ // Not null here: a process stopped by a signal was rejected above.
82
+ exitCode: code ?? 1,
83
+ });
84
+ });
85
+ });
86
+ }
87
+ /** The first line of a program's error output, which is the part a model can act on. */
88
+ export function firstLine(text) {
89
+ return text.trim().split('\n')[0] ?? '';
90
+ }
91
+ //# sourceMappingURL=runProcess.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runProcess.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/runProcess.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process';\nimport { StringDecoder } from 'node:string_decoder';\n\n// What a program may print, or one line of it may hold, before it is stopped. Beyond this it\n// has printed far more than anyone can read.\nconst maxOutputBytes = 64 * 1024 * 1024;\n\nexport interface ProcessResult {\n\t/** Empty when the output went to `onLine` instead. */\n\tstdout: string;\n\tstderr: string;\n\texitCode: number;\n}\n\n/**\n * Runs a program with its arguments as an array, never through a shell, so nothing a model\n * writes can become a command. Standard input is closed: ripgrep given no path searches its\n * standard input whenever that is a pipe. A cancelled signal kills the process and rejects\n * with the signal's `AbortError`.\n *\n * With `onLine`, each line of the output is handed over as it arrives and nothing is kept, so\n * the caller decides what to hold; only a single line is then bound by the size limit. An\n * error thrown by `onLine` stops the program and rejects with that error.\n */\nexport function runProcess(\n\tcommand: string,\n\targs: string[],\n\t{ cwd, signal, onLine }: { cwd: string; signal: AbortSignal; onLine?: (line: string) => void },\n): Promise<ProcessResult> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst child = spawn(command, args, { cwd, signal, stdio: ['ignore', 'pipe', 'pipe'] });\n\t\tconst decoder = new StringDecoder('utf8');\n\t\tconst stdout: string[] = [];\n\t\tconst stderr: Buffer[] = [];\n\t\tlet pending = '';\n\t\tlet size = 0;\n\t\tlet failure: Error | undefined;\n\n\t\tconst stop = (error: Error) => {\n\t\t\tfailure ??= error;\n\t\t\tchild.kill();\n\t\t};\n\n\t\tconst deliver = (text: string) => {\n\t\t\tif (!onLine) {\n\t\t\t\tstdout.push(text);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst lines = (pending + text).split('\\n');\n\t\t\tpending = lines.pop() ?? '';\n\t\t\tfor (const line of lines) onLine(line);\n\t\t};\n\n\t\tchild.stdout.on('data', (chunk: Buffer) => {\n\t\t\tif (failure) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsize = onLine ? pending.length + chunk.length : size + chunk.length;\n\t\t\tif (size > maxOutputBytes) {\n\t\t\t\tstop(new Error(`The program printed more than ${String(maxOutputBytes / 1024 / 1024)} MB`));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tdeliver(decoder.write(chunk));\n\t\t\t} catch (error) {\n\t\t\t\tstop(error instanceof Error ? error : new Error(String(error)));\n\t\t\t}\n\t\t});\n\t\tchild.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));\n\n\t\t// A missing program, or the cancellation, which arrives as an `AbortError`.\n\t\tchild.on('error', reject);\n\t\tchild.on('close', (code, killedBy) => {\n\t\t\t// Stopped from outside, such as by the out-of-memory killer: what it printed is not the\n\t\t\t// whole answer, and an exit code would pass it off as one. Its own stops set `failure`.\n\t\t\tif (code === null && !failure) {\n\t\t\t\tfailure = new Error(\n\t\t\t\t\t`${command} was stopped by ${killedBy ?? 'a signal'} before it finished`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (!failure) {\n\t\t\t\ttry {\n\t\t\t\t\tdeliver(decoder.end());\n\t\t\t\t\tif (onLine && pending !== '') onLine(pending);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tfailure = error instanceof Error ? error : new Error(String(error));\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (failure) {\n\t\t\t\treject(failure);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tresolve({\n\t\t\t\tstdout: stdout.join(''),\n\t\t\t\tstderr: Buffer.concat(stderr).toString('utf8'),\n\t\t\t\t// Not null here: a process stopped by a signal was rejected above.\n\t\t\t\texitCode: code ?? 1,\n\t\t\t});\n\t\t});\n\t});\n}\n\n/** The first line of a program's error output, which is the part a model can act on. */\nexport function firstLine(text: string): string {\n\treturn text.trim().split('\\n')[0] ?? '';\n}\n"]}
@@ -0,0 +1,4 @@
1
+ import type { Tool } from '../domain/tool.ts';
2
+ import type { Workspace } from '../domain/workspace.ts';
3
+ /** The three read-only tools an agent explores a repository with. */
4
+ export declare function createWorkspaceTools(workspace: Workspace): Tool[];
@@ -0,0 +1,110 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from "./defineTool.js";
3
+ // What one call may return. The model can ask for fewer lines, never for more.
4
+ const maxFiles = 200;
5
+ const maxMatches = 100;
6
+ const maxLines = 200;
7
+ // One minified line can be larger than everything else the model has read.
8
+ const maxLineLength = 300;
9
+ // Strict mode has no optional fields: an argument the model may leave out is nullable.
10
+ const scope = {
11
+ path: z.string().nullable().describe('A folder or file to stay inside, relative to the root.'),
12
+ glob: z.string().nullable().describe('Only paths matching this glob, such as "**/*.ts".'),
13
+ };
14
+ /** One line as the model reads it: a minified bundle on a single line would flood its context. */
15
+ function cutLine(text) {
16
+ return text.length > maxLineLength ? `${text.slice(0, maxLineLength)}…` : text;
17
+ }
18
+ function assertPositiveInteger(name, value) {
19
+ if (!Number.isInteger(value) || value < 1) {
20
+ throw new Error(`${name} must be a whole number of at least 1; got ${String(value)}`);
21
+ }
22
+ }
23
+ /** The three read-only tools an agent explores a repository with. */
24
+ export function createWorkspaceTools(workspace) {
25
+ return [
26
+ defineTool({
27
+ name: 'listFiles',
28
+ description: 'Lists the files of the repository, sorted. Ignored files such as dependencies are left out.',
29
+ input: z.object(scope),
30
+ execute: async ({ path, glob }, signal) => {
31
+ // The port takes "no scope" as a missing field.
32
+ const { files, total, truncated } = await workspace.listFiles({ path: path ?? undefined, glob: glob ?? undefined, limit: maxFiles }, signal);
33
+ // Nothing found is an answer too, not an empty string.
34
+ if (files.length === 0) {
35
+ return 'No files.';
36
+ }
37
+ const notice = truncated
38
+ ? `\n[Showing ${String(files.length)} of ${String(total)} files. Narrow the path or glob to see the rest.]`
39
+ : '';
40
+ return files.join('\n') + notice;
41
+ },
42
+ }),
43
+ defineTool({
44
+ name: 'searchText',
45
+ description: 'Searches the repository for a regular expression and returns each matching line as path:line: text.',
46
+ input: z.object({
47
+ pattern: z.string().describe('A regular expression.'),
48
+ ignoreCase: z.boolean().nullable().describe('Match regardless of case. Defaults to false.'),
49
+ ...scope,
50
+ }),
51
+ execute: async ({ path, glob, ignoreCase, pattern }, signal) => {
52
+ const { matches, total, truncated } = await workspace.searchText({
53
+ path: path ?? undefined,
54
+ glob: glob ?? undefined,
55
+ ignoreCase: ignoreCase ?? false,
56
+ pattern,
57
+ limit: maxMatches,
58
+ }, signal);
59
+ if (matches.length === 0) {
60
+ return 'No matches.';
61
+ }
62
+ const results = matches.map(match => `${match.path}:${String(match.line)}: ${cutLine(match.text)}`);
63
+ const notice = truncated
64
+ ? `\n[Showing ${String(results.length)} of ${String(total)} matches. Narrow the path or glob to see the rest.]`
65
+ : '';
66
+ return results.join('\n') + notice;
67
+ },
68
+ }),
69
+ defineTool({
70
+ name: 'readFile',
71
+ description: 'Reads lines of a file, each prefixed with its number.',
72
+ input: z.object({
73
+ path: z.string().describe('The file, relative to the root.'),
74
+ fromLine: z.number().nullable().describe('The first line to read, from 1. Defaults to 1.'),
75
+ lineCount: z
76
+ .number()
77
+ .nullable()
78
+ .describe(`How many lines to read, at most ${String(maxLines)}. Defaults to ${String(maxLines)}.`),
79
+ }),
80
+ execute: async ({ fromLine, lineCount, path }, signal) => {
81
+ const from = fromLine ?? 1;
82
+ const requested = lineCount ?? maxLines;
83
+ // Checked here, before reading, because strict mode allows the schema no minimum.
84
+ assertPositiveInteger('fromLine', from);
85
+ assertPositiveInteger('lineCount', requested);
86
+ const { lines, totalLines, truncated } = await workspace.readFile({ path, fromLine: from, lineCount: Math.min(requested, maxLines) }, signal);
87
+ // The file's length tells the model where its range went wrong.
88
+ if (lines.length === 0) {
89
+ return `No lines from ${String(from)}: the file has ${String(totalLines)} lines.`;
90
+ }
91
+ const numbered = lines.map((text, index) => `${String(from + index)}: ${cutLine(text)}`);
92
+ const cut = lines.filter(text => text.length > maxLineLength).length;
93
+ const notices = [
94
+ ...(cut > 0
95
+ ? [
96
+ `[${String(cut)} lines were longer than ${String(maxLineLength)} characters and were cut.]`,
97
+ ]
98
+ : []),
99
+ ...(truncated
100
+ ? [
101
+ `[The file has ${String(totalLines)} lines. Read from line ${String(from + lines.length)} to continue.]`,
102
+ ]
103
+ : []),
104
+ ];
105
+ return [...numbered, ...notices].join('\n');
106
+ },
107
+ }),
108
+ ];
109
+ }
110
+ //# sourceMappingURL=workspaceTools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspaceTools.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/workspaceTools.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from 'zod';\nimport type { Tool } from '../domain/tool.ts';\nimport type { Workspace } from '../domain/workspace.ts';\nimport { defineTool } from './defineTool.ts';\n\n// What one call may return. The model can ask for fewer lines, never for more.\nconst maxFiles = 200;\nconst maxMatches = 100;\nconst maxLines = 200;\n// One minified line can be larger than everything else the model has read.\nconst maxLineLength = 300;\n\n// Strict mode has no optional fields: an argument the model may leave out is nullable.\nconst scope = {\n\tpath: z.string().nullable().describe('A folder or file to stay inside, relative to the root.'),\n\tglob: z.string().nullable().describe('Only paths matching this glob, such as \"**/*.ts\".'),\n};\n\n/** One line as the model reads it: a minified bundle on a single line would flood its context. */\nfunction cutLine(text: string): string {\n\treturn text.length > maxLineLength ? `${text.slice(0, maxLineLength)}…` : text;\n}\n\nfunction assertPositiveInteger(name: string, value: number): void {\n\tif (!Number.isInteger(value) || value < 1) {\n\t\tthrow new Error(`${name} must be a whole number of at least 1; got ${String(value)}`);\n\t}\n}\n\n/** The three read-only tools an agent explores a repository with. */\nexport function createWorkspaceTools(workspace: Workspace): Tool[] {\n\treturn [\n\t\tdefineTool({\n\t\t\tname: 'listFiles',\n\t\t\tdescription:\n\t\t\t\t'Lists the files of the repository, sorted. Ignored files such as dependencies are left out.',\n\t\t\tinput: z.object(scope),\n\t\t\texecute: async ({ path, glob }, signal) => {\n\t\t\t\t// The port takes \"no scope\" as a missing field.\n\t\t\t\tconst { files, total, truncated } = await workspace.listFiles(\n\t\t\t\t\t{ path: path ?? undefined, glob: glob ?? undefined, limit: maxFiles },\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\n\t\t\t\t// Nothing found is an answer too, not an empty string.\n\t\t\t\tif (files.length === 0) {\n\t\t\t\t\treturn 'No files.';\n\t\t\t\t}\n\n\t\t\t\tconst notice = truncated\n\t\t\t\t\t? `\\n[Showing ${String(files.length)} of ${String(total)} files. Narrow the path or glob to see the rest.]`\n\t\t\t\t\t: '';\n\t\t\t\treturn files.join('\\n') + notice;\n\t\t\t},\n\t\t}),\n\n\t\tdefineTool({\n\t\t\tname: 'searchText',\n\t\t\tdescription:\n\t\t\t\t'Searches the repository for a regular expression and returns each matching line as path:line: text.',\n\t\t\tinput: z.object({\n\t\t\t\tpattern: z.string().describe('A regular expression.'),\n\t\t\t\tignoreCase: z.boolean().nullable().describe('Match regardless of case. Defaults to false.'),\n\t\t\t\t...scope,\n\t\t\t}),\n\t\t\texecute: async ({ path, glob, ignoreCase, pattern }, signal) => {\n\t\t\t\tconst { matches, total, truncated } = await workspace.searchText(\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: path ?? undefined,\n\t\t\t\t\t\tglob: glob ?? undefined,\n\t\t\t\t\t\tignoreCase: ignoreCase ?? false,\n\t\t\t\t\t\tpattern,\n\t\t\t\t\t\tlimit: maxMatches,\n\t\t\t\t\t},\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\n\t\t\t\tif (matches.length === 0) {\n\t\t\t\t\treturn 'No matches.';\n\t\t\t\t}\n\n\t\t\t\tconst results = matches.map(\n\t\t\t\t\tmatch => `${match.path}:${String(match.line)}: ${cutLine(match.text)}`,\n\t\t\t\t);\n\n\t\t\t\tconst notice = truncated\n\t\t\t\t\t? `\\n[Showing ${String(results.length)} of ${String(total)} matches. Narrow the path or glob to see the rest.]`\n\t\t\t\t\t: '';\n\t\t\t\treturn results.join('\\n') + notice;\n\t\t\t},\n\t\t}),\n\n\t\tdefineTool({\n\t\t\tname: 'readFile',\n\t\t\tdescription: 'Reads lines of a file, each prefixed with its number.',\n\t\t\tinput: z.object({\n\t\t\t\tpath: z.string().describe('The file, relative to the root.'),\n\t\t\t\tfromLine: z.number().nullable().describe('The first line to read, from 1. Defaults to 1.'),\n\t\t\t\tlineCount: z\n\t\t\t\t\t.number()\n\t\t\t\t\t.nullable()\n\t\t\t\t\t.describe(\n\t\t\t\t\t\t`How many lines to read, at most ${String(maxLines)}. Defaults to ${String(maxLines)}.`,\n\t\t\t\t\t),\n\t\t\t}),\n\t\t\texecute: async ({ fromLine, lineCount, path }, signal) => {\n\t\t\t\tconst from = fromLine ?? 1;\n\t\t\t\tconst requested = lineCount ?? maxLines;\n\t\t\t\t// Checked here, before reading, because strict mode allows the schema no minimum.\n\t\t\t\tassertPositiveInteger('fromLine', from);\n\t\t\t\tassertPositiveInteger('lineCount', requested);\n\n\t\t\t\tconst { lines, totalLines, truncated } = await workspace.readFile(\n\t\t\t\t\t{ path, fromLine: from, lineCount: Math.min(requested, maxLines) },\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\n\t\t\t\t// The file's length tells the model where its range went wrong.\n\t\t\t\tif (lines.length === 0) {\n\t\t\t\t\treturn `No lines from ${String(from)}: the file has ${String(totalLines)} lines.`;\n\t\t\t\t}\n\n\t\t\t\tconst numbered = lines.map((text, index) => `${String(from + index)}: ${cutLine(text)}`);\n\t\t\t\tconst cut = lines.filter(text => text.length > maxLineLength).length;\n\t\t\t\tconst notices = [\n\t\t\t\t\t...(cut > 0\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t`[${String(cut)} lines were longer than ${String(maxLineLength)} characters and were cut.]`,\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: []),\n\t\t\t\t\t...(truncated\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t`[The file has ${String(totalLines)} lines. Read from line ${String(from + lines.length)} to continue.]`,\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: []),\n\t\t\t\t];\n\t\t\t\treturn [...numbered, ...notices].join('\\n');\n\t\t\t},\n\t\t}),\n\t];\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikode13/harness",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Test/agent harness utility",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -63,6 +63,7 @@
63
63
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",
64
64
  "@anthropic-ai/sdk": "^0.115.0",
65
65
  "@openai/codex-sdk": "^0.154.0",
66
+ "@vscode/ripgrep": "^1.18.0",
66
67
  "openai": "^7.23.0",
67
68
  "zod": "^4.4.3"
68
69
  }