@mikode13/harness 4.0.0 → 5.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 (168) hide show
  1. package/README.md +285 -20
  2. package/dist/agent/domain/agent.d.ts +12 -0
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/approval.d.ts +14 -0
  5. package/dist/agent/domain/approval.js +28 -3
  6. package/dist/agent/domain/approval.js.map +1 -1
  7. package/dist/agent/domain/runContext.d.ts +31 -0
  8. package/dist/agent/domain/runContext.js +47 -0
  9. package/dist/agent/domain/runContext.js.map +1 -0
  10. package/dist/diff/domain/unifiedDiff.d.ts +19 -0
  11. package/dist/diff/domain/unifiedDiff.js +166 -0
  12. package/dist/diff/domain/unifiedDiff.js.map +1 -0
  13. package/dist/engines/domain/model/historyFollower.d.ts +41 -0
  14. package/dist/engines/domain/model/historyFollower.js +158 -0
  15. package/dist/engines/domain/model/historyFollower.js.map +1 -0
  16. package/dist/engines/domain/model/llmAgent.d.ts +42 -4
  17. package/dist/engines/domain/model/llmAgent.js +224 -36
  18. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  19. package/dist/factory/infrastructure/agentFactory.d.ts +14 -0
  20. package/dist/factory/infrastructure/agentFactory.js +20 -2
  21. package/dist/factory/infrastructure/agentFactory.js.map +1 -1
  22. package/dist/factory/infrastructure/agentLLMFactory.d.ts +119 -14
  23. package/dist/factory/infrastructure/agentLLMFactory.js +284 -57
  24. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  25. package/dist/index.d.ts +11 -3
  26. package/dist/index.js +5 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/llm/domain/conversation.d.ts +44 -4
  29. package/dist/llm/domain/conversation.js +56 -7
  30. package/dist/llm/domain/conversation.js.map +1 -1
  31. package/dist/llm/domain/llm.d.ts +3 -1
  32. package/dist/llm/domain/llm.js.map +1 -1
  33. package/dist/llm/domain/tool.d.ts +13 -0
  34. package/dist/llm/domain/tool.js +12 -1
  35. package/dist/llm/domain/tool.js.map +1 -1
  36. package/dist/llm/infrastructure/claudeLLMClient.d.ts +3 -2
  37. package/dist/llm/infrastructure/claudeLLMClient.js +38 -10
  38. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  39. package/dist/llm/infrastructure/openAILLMClient.d.ts +2 -1
  40. package/dist/llm/infrastructure/openAILLMClient.js +66 -16
  41. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  42. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +11 -2
  43. package/dist/orchestration/domain/model/orchestratorAgent.js +43 -4
  44. package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
  45. package/dist/recovery/domain/fileChanges.d.ts +31 -0
  46. package/dist/recovery/domain/fileChanges.js +48 -0
  47. package/dist/recovery/domain/fileChanges.js.map +1 -0
  48. package/dist/recovery/domain/history.d.ts +114 -0
  49. package/dist/recovery/domain/history.js +62 -0
  50. package/dist/recovery/domain/history.js.map +1 -0
  51. package/dist/recovery/domain/recoveryStore.d.ts +233 -0
  52. package/dist/recovery/domain/recoveryStore.js +32 -0
  53. package/dist/recovery/domain/recoveryStore.js.map +1 -0
  54. package/dist/recovery/domain/retention.d.ts +33 -0
  55. package/dist/recovery/domain/retention.js +90 -0
  56. package/dist/recovery/domain/retention.js.map +1 -0
  57. package/dist/recovery/domain/runChanges.d.ts +37 -0
  58. package/dist/recovery/domain/runChanges.js +109 -0
  59. package/dist/recovery/domain/runChanges.js.map +1 -0
  60. package/dist/recovery/domain/runTree.d.ts +32 -0
  61. package/dist/recovery/domain/runTree.js +90 -0
  62. package/dist/recovery/domain/runTree.js.map +1 -0
  63. package/dist/recovery/infrastructure/createHistory.d.ts +11 -0
  64. package/dist/recovery/infrastructure/createHistory.js +17 -0
  65. package/dist/recovery/infrastructure/createHistory.js.map +1 -0
  66. package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +133 -0
  67. package/dist/recovery/infrastructure/fileRecoveryStore.js +1043 -0
  68. package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -0
  69. package/dist/recovery/infrastructure/workspaceFiles.d.ts +30 -0
  70. package/dist/recovery/infrastructure/workspaceFiles.js +125 -0
  71. package/dist/recovery/infrastructure/workspaceFiles.js.map +1 -0
  72. package/dist/tools/domain/accessPolicy.d.ts +30 -0
  73. package/dist/tools/domain/accessPolicy.js +11 -0
  74. package/dist/tools/domain/accessPolicy.js.map +1 -0
  75. package/dist/tools/domain/commands.d.ts +93 -0
  76. package/dist/tools/domain/commands.js +191 -0
  77. package/dist/tools/domain/commands.js.map +1 -0
  78. package/dist/tools/domain/editResponse.d.ts +14 -0
  79. package/dist/tools/domain/editResponse.js +79 -0
  80. package/dist/tools/domain/editResponse.js.map +1 -0
  81. package/dist/tools/domain/fileEdits.d.ts +64 -0
  82. package/dist/tools/domain/fileEdits.js +21 -0
  83. package/dist/tools/domain/fileEdits.js.map +1 -0
  84. package/dist/tools/domain/lineLength.d.ts +7 -0
  85. package/dist/tools/domain/lineLength.js +26 -0
  86. package/dist/tools/domain/lineLength.js.map +1 -0
  87. package/dist/tools/domain/preparedCall.d.ts +31 -0
  88. package/dist/tools/domain/preparedCall.js +14 -0
  89. package/dist/tools/domain/preparedCall.js.map +1 -0
  90. package/dist/tools/domain/readRegistry.d.ts +46 -0
  91. package/dist/tools/domain/readRegistry.js +62 -0
  92. package/dist/tools/domain/readRegistry.js.map +1 -0
  93. package/dist/tools/domain/textLines.d.ts +26 -0
  94. package/dist/tools/domain/textLines.js +44 -0
  95. package/dist/tools/domain/textLines.js.map +1 -0
  96. package/dist/tools/domain/v4aPatch.d.ts +17 -0
  97. package/dist/tools/domain/v4aPatch.js +236 -0
  98. package/dist/tools/domain/v4aPatch.js.map +1 -0
  99. package/dist/tools/infrastructure/boundedWorkspace.d.ts +11 -2
  100. package/dist/tools/infrastructure/boundedWorkspace.js +9 -1
  101. package/dist/tools/infrastructure/boundedWorkspace.js.map +1 -1
  102. package/dist/tools/infrastructure/commandCheckpoint.d.ts +55 -0
  103. package/dist/tools/infrastructure/commandCheckpoint.js +178 -0
  104. package/dist/tools/infrastructure/commandCheckpoint.js.map +1 -0
  105. package/dist/tools/infrastructure/commandProcess.d.ts +47 -0
  106. package/dist/tools/infrastructure/commandProcess.js +217 -0
  107. package/dist/tools/infrastructure/commandProcess.js.map +1 -0
  108. package/dist/tools/infrastructure/commandTool.d.ts +36 -0
  109. package/dist/tools/infrastructure/commandTool.js +254 -0
  110. package/dist/tools/infrastructure/commandTool.js.map +1 -0
  111. package/dist/tools/infrastructure/createWorkspace.d.ts +10 -0
  112. package/dist/tools/infrastructure/createWorkspace.js +11 -3
  113. package/dist/tools/infrastructure/createWorkspace.js.map +1 -1
  114. package/dist/tools/infrastructure/defineTool.d.ts +14 -0
  115. package/dist/tools/infrastructure/defineTool.js +28 -5
  116. package/dist/tools/infrastructure/defineTool.js.map +1 -1
  117. package/dist/tools/infrastructure/editFormats.d.ts +22 -0
  118. package/dist/tools/infrastructure/editFormats.js +211 -0
  119. package/dist/tools/infrastructure/editFormats.js.map +1 -0
  120. package/dist/tools/infrastructure/fileEditor.d.ts +64 -0
  121. package/dist/tools/infrastructure/fileEditor.js +306 -0
  122. package/dist/tools/infrastructure/fileEditor.js.map +1 -0
  123. package/dist/tools/infrastructure/fileTools.d.ts +81 -0
  124. package/dist/tools/infrastructure/fileTools.js +152 -0
  125. package/dist/tools/infrastructure/fileTools.js.map +1 -0
  126. package/dist/tools/infrastructure/gitIgnoreRules.d.ts +15 -0
  127. package/dist/tools/infrastructure/gitIgnoreRules.js +74 -0
  128. package/dist/tools/infrastructure/gitIgnoreRules.js.map +1 -0
  129. package/dist/tools/infrastructure/gitWorkspace.d.ts +1 -1
  130. package/dist/tools/infrastructure/gitWorkspace.js +1 -1
  131. package/dist/tools/infrastructure/gitWorkspace.js.map +1 -1
  132. package/dist/tools/infrastructure/ignoreFileChanges.d.ts +14 -0
  133. package/dist/tools/infrastructure/ignoreFileChanges.js +62 -0
  134. package/dist/tools/infrastructure/ignoreFileChanges.js.map +1 -0
  135. package/dist/tools/infrastructure/policyWorkspace.d.ts +52 -0
  136. package/dist/tools/infrastructure/policyWorkspace.js +32 -0
  137. package/dist/tools/infrastructure/policyWorkspace.js.map +1 -0
  138. package/dist/tools/infrastructure/ripgrepWorkspace.d.ts +2 -1
  139. package/dist/tools/infrastructure/ripgrepWorkspace.js +1 -1
  140. package/dist/tools/infrastructure/ripgrepWorkspace.js.map +1 -1
  141. package/dist/tools/infrastructure/rootsAccessPolicy.d.ts +100 -0
  142. package/dist/tools/infrastructure/rootsAccessPolicy.js +301 -0
  143. package/dist/tools/infrastructure/rootsAccessPolicy.js.map +1 -0
  144. package/dist/tools/infrastructure/runProcess.d.ts +4 -1
  145. package/dist/tools/infrastructure/runProcess.js +4 -2
  146. package/dist/tools/infrastructure/runProcess.js.map +1 -1
  147. package/dist/tools/infrastructure/secretPaths.d.ts +8 -0
  148. package/dist/tools/infrastructure/secretPaths.js +76 -0
  149. package/dist/tools/infrastructure/secretPaths.js.map +1 -0
  150. package/dist/tools/infrastructure/showChangesTool.d.ts +12 -0
  151. package/dist/tools/infrastructure/showChangesTool.js +43 -0
  152. package/dist/tools/infrastructure/showChangesTool.js.map +1 -0
  153. package/dist/tools/infrastructure/trackedEdits.d.ts +33 -0
  154. package/dist/tools/infrastructure/trackedEdits.js +70 -0
  155. package/dist/tools/infrastructure/trackedEdits.js.map +1 -0
  156. package/dist/tools/infrastructure/trackedReadFile.d.ts +21 -0
  157. package/dist/tools/infrastructure/trackedReadFile.js +78 -0
  158. package/dist/tools/infrastructure/trackedReadFile.js.map +1 -0
  159. package/dist/tools/infrastructure/workspaceTools.d.ts +28 -0
  160. package/dist/tools/infrastructure/workspaceTools.js +53 -40
  161. package/dist/tools/infrastructure/workspaceTools.js.map +1 -1
  162. package/dist/tools/infrastructure/workspaceWrites.d.ts +23 -0
  163. package/dist/tools/infrastructure/workspaceWrites.js +42 -0
  164. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -0
  165. package/dist/tools/infrastructure/writeSession.d.ts +35 -0
  166. package/dist/tools/infrastructure/writeSession.js +81 -0
  167. package/dist/tools/infrastructure/writeSession.js.map +1 -0
  168. package/package.json +1 -1
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What one conversation has seen of each file: the hash of the content a read showed it, or an
3
+ * edit of its own left. An edit is made only from that version, so the model never changes a
4
+ * file it has not read, or one that changed since.
5
+ *
6
+ * Keyed by the file's real path, so two spellings of one file are one entry. It belongs to the
7
+ * conversation, not to the run: each role, with its own conversation, has its own.
8
+ */
9
+ export class ReadRegistry {
10
+ seen = new Map();
11
+ /** The reads of one run, which count for the conversation only once the run succeeded. */
12
+ begin() {
13
+ return new RunReads(this.seen, seen => {
14
+ this.seen = seen;
15
+ });
16
+ }
17
+ /**
18
+ * Forgets every file: the workspace moved through its history, so what the conversation saw
19
+ * may no longer be what the files hold.
20
+ */
21
+ clear() {
22
+ this.seen = new Map();
23
+ }
24
+ }
25
+ /**
26
+ * The versions one run has seen, on top of what its conversation saw before it. A failed run
27
+ * records nothing in its conversation, so the model does not remember what it read there: its
28
+ * reads are kept only by `commit`.
29
+ */
30
+ export class RunReads {
31
+ base;
32
+ own = new Map();
33
+ save;
34
+ constructor(base, save) {
35
+ this.base = base;
36
+ this.save = save;
37
+ }
38
+ /** The version of `absolute` the conversation has seen, if any. */
39
+ versionOf(absolute) {
40
+ return this.own.has(absolute) ? this.own.get(absolute) : this.base.get(absolute);
41
+ }
42
+ /** The conversation now knows `absolute` as holding the content `hash` names. */
43
+ record(absolute, hash) {
44
+ this.own.set(absolute, hash);
45
+ }
46
+ /** The file is gone, as the conversation knows it. */
47
+ forget(absolute) {
48
+ this.own.set(absolute, undefined);
49
+ }
50
+ /** Keeps this run's reads for the conversation's next runs. */
51
+ commit() {
52
+ const seen = new Map(this.base);
53
+ for (const [path, hash] of this.own) {
54
+ if (hash === undefined)
55
+ seen.delete(path);
56
+ else
57
+ seen.set(path, hash);
58
+ }
59
+ this.save(seen);
60
+ }
61
+ }
62
+ //# sourceMappingURL=readRegistry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readRegistry.js","sourceRoot":"","sources":["../../../src/tools/domain/readRegistry.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,MAAM,OAAO,YAAY;IAChB,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IAEzC,0FAA0F;IAC1F,KAAK;QACJ,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE;YACrC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QAClB,CAAC,CAAC,CAAC;IACJ,CAAC;IAED;;;OAGG;IACH,KAAK;QACJ,IAAI,CAAC,IAAI,GAAG,IAAI,GAAG,EAAE,CAAC;IACvB,CAAC;CACD;AAED;;;;GAIG;AACH,MAAM,OAAO,QAAQ;IACH,IAAI,CAA8B;IAClC,GAAG,GAAG,IAAI,GAAG,EAA8B,CAAC;IAC5C,IAAI,CAAsC;IAE3D,YAAY,IAAiC,EAAE,IAAyC;QACvF,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IAClB,CAAC;IAED,mEAAmE;IACnE,SAAS,CAAC,QAAgB;QACzB,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAClF,CAAC;IAED,iFAAiF;IACjF,MAAM,CAAC,QAAgB,EAAE,IAAY;QACpC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IAC9B,CAAC;IAED,sDAAsD;IACtD,MAAM,CAAC,QAAgB;QACtB,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;IACnC,CAAC;IAED,+DAA+D;IAC/D,MAAM;QACL,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChC,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;YACrC,IAAI,IAAI,KAAK,SAAS;gBAAE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;;gBACrC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAC3B,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;CACD","sourcesContent":["import type { RunContext } from '#src/agent/domain/runContext';\n\n/**\n * What one conversation has seen of each file: the hash of the content a read showed it, or an\n * edit of its own left. An edit is made only from that version, so the model never changes a\n * file it has not read, or one that changed since.\n *\n * Keyed by the file's real path, so two spellings of one file are one entry. It belongs to the\n * conversation, not to the run: each role, with its own conversation, has its own.\n */\nexport class ReadRegistry {\n\tprivate seen = new Map<string, string>();\n\n\t/** The reads of one run, which count for the conversation only once the run succeeded. */\n\tbegin(): RunReads {\n\t\treturn new RunReads(this.seen, seen => {\n\t\t\tthis.seen = seen;\n\t\t});\n\t}\n\n\t/**\n\t * Forgets every file: the workspace moved through its history, so what the conversation saw\n\t * may no longer be what the files hold.\n\t */\n\tclear(): void {\n\t\tthis.seen = new Map();\n\t}\n}\n\n/**\n * The versions one run has seen, on top of what its conversation saw before it. A failed run\n * records nothing in its conversation, so the model does not remember what it read there: its\n * reads are kept only by `commit`.\n */\nexport class RunReads {\n\tprivate readonly base: ReadonlyMap<string, string>;\n\tprivate readonly own = new Map<string, string | undefined>();\n\tprivate readonly save: (seen: Map<string, string>) => void;\n\n\tconstructor(base: ReadonlyMap<string, string>, save: (seen: Map<string, string>) => void) {\n\t\tthis.base = base;\n\t\tthis.save = save;\n\t}\n\n\t/** The version of `absolute` the conversation has seen, if any. */\n\tversionOf(absolute: string): string | undefined {\n\t\treturn this.own.has(absolute) ? this.own.get(absolute) : this.base.get(absolute);\n\t}\n\n\t/** The conversation now knows `absolute` as holding the content `hash` names. */\n\trecord(absolute: string, hash: string): void {\n\t\tthis.own.set(absolute, hash);\n\t}\n\n\t/** The file is gone, as the conversation knows it. */\n\tforget(absolute: string): void {\n\t\tthis.own.set(absolute, undefined);\n\t}\n\n\t/** Keeps this run's reads for the conversation's next runs. */\n\tcommit(): void {\n\t\tconst seen = new Map(this.base);\n\t\tfor (const [path, hash] of this.own) {\n\t\t\tif (hash === undefined) seen.delete(path);\n\t\t\telse seen.set(path, hash);\n\t\t}\n\t\tthis.save(seen);\n\t}\n}\n\n/**\n * What a harness-built tool gets for one call: the run it belongs to, shared by every role, and\n * the reads of the conversation that made the call.\n */\nexport interface ToolCallContext {\n\treadonly run: RunContext;\n\treadonly reads: RunReads;\n}\n"]}
@@ -0,0 +1,26 @@
1
+ /** One line of a text, and the ending it had: a file may mix `\n` and `\r\n`. */
2
+ export interface Line {
3
+ text: string;
4
+ end: '' | '\n' | '\r\n';
5
+ }
6
+ /**
7
+ * A text as lines, each with its own ending, so an edit writes back every line it did not touch
8
+ * exactly as it was. `eol` is the ending most lines have, for lines that have none of their own.
9
+ */
10
+ export interface TextLines {
11
+ lines: Line[];
12
+ eol: '\n' | '\r\n';
13
+ finalNewline: boolean;
14
+ }
15
+ export declare function splitLines(text: string): TextLines;
16
+ export declare function textsOf(lines: Line[]): string[];
17
+ /**
18
+ * A line an edit adds, with the ending of the line before it, so it reads like its neighbours;
19
+ * the text's usual ending when there is none.
20
+ */
21
+ export declare function addedLine(text: string, before: Line | undefined, eol: TextLines['eol']): Line;
22
+ /**
23
+ * The text again. A line that gained a line after it gets an ending if it had none; the last line
24
+ * ends as the text's last line did, with a newline or without.
25
+ */
26
+ export declare function joinLines({ lines, eol, finalNewline }: TextLines): string;
@@ -0,0 +1,44 @@
1
+ export function splitLines(text) {
2
+ const lines = [];
3
+ let crlf = 0;
4
+ let lf = 0;
5
+ let start = 0;
6
+ for (const match of text.matchAll(/\r?\n/g)) {
7
+ const end = match[0];
8
+ if (end === '\r\n')
9
+ crlf++;
10
+ else
11
+ lf++;
12
+ lines.push({ text: text.slice(start, match.index), end });
13
+ start = match.index + end.length;
14
+ }
15
+ if (start < text.length)
16
+ lines.push({ text: text.slice(start), end: '' });
17
+ return { lines, eol: crlf > lf ? '\r\n' : '\n', finalNewline: text.endsWith('\n') };
18
+ }
19
+ export function textsOf(lines) {
20
+ return lines.map(line => line.text);
21
+ }
22
+ /**
23
+ * A line an edit adds, with the ending of the line before it, so it reads like its neighbours;
24
+ * the text's usual ending when there is none.
25
+ */
26
+ export function addedLine(text, before, eol) {
27
+ // A line with no ending, the last one, lends none.
28
+ const end = before?.end ?? '';
29
+ return { text, end: end === '' ? eol : end };
30
+ }
31
+ /**
32
+ * The text again. A line that gained a line after it gets an ending if it had none; the last line
33
+ * ends as the text's last line did, with a newline or without.
34
+ */
35
+ export function joinLines({ lines, eol, finalNewline }) {
36
+ return lines
37
+ .map((line, index) => {
38
+ if (index < lines.length - 1)
39
+ return line.text + (line.end || eol);
40
+ return line.text + (finalNewline ? line.end || eol : '');
41
+ })
42
+ .join('');
43
+ }
44
+ //# sourceMappingURL=textLines.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textLines.js","sourceRoot":"","sources":["../../../src/tools/domain/textLines.ts"],"names":[],"mappings":"AAgBA,MAAM,UAAU,UAAU,CAAC,IAAY;IACtC,MAAM,KAAK,GAAW,EAAE,CAAC;IACzB,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,EAAE,GAAG,CAAC,CAAC;IACX,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC7C,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAkB,CAAC;QACtC,IAAI,GAAG,KAAK,MAAM;YAAE,IAAI,EAAE,CAAC;;YACtB,EAAE,EAAE,CAAC;QACV,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC;QAC1D,KAAK,GAAG,KAAK,CAAC,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC;IAClC,CAAC;IACD,IAAI,KAAK,GAAG,IAAI,CAAC,MAAM;QAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC,CAAC;IAC1E,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;AACrF,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,KAAa;IACpC,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACrC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,MAAwB,EAAE,GAAqB;IACtF,mDAAmD;IACnD,MAAM,GAAG,GAAG,MAAM,EAAE,GAAG,IAAI,EAAE,CAAC;IAC9B,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;AAC9C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,YAAY,EAAa;IAChE,OAAO,KAAK;SACV,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;QACpB,IAAI,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC,IAAI,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC1D,CAAC,CAAC;SACD,IAAI,CAAC,EAAE,CAAC,CAAC;AACZ,CAAC","sourcesContent":["/** One line of a text, and the ending it had: a file may mix `\\n` and `\\r\\n`. */\nexport interface Line {\n\ttext: string;\n\tend: '' | '\\n' | '\\r\\n';\n}\n\n/**\n * A text as lines, each with its own ending, so an edit writes back every line it did not touch\n * exactly as it was. `eol` is the ending most lines have, for lines that have none of their own.\n */\nexport interface TextLines {\n\tlines: Line[];\n\teol: '\\n' | '\\r\\n';\n\tfinalNewline: boolean;\n}\n\nexport function splitLines(text: string): TextLines {\n\tconst lines: Line[] = [];\n\tlet crlf = 0;\n\tlet lf = 0;\n\tlet start = 0;\n\tfor (const match of text.matchAll(/\\r?\\n/g)) {\n\t\tconst end = match[0] as '\\n' | '\\r\\n';\n\t\tif (end === '\\r\\n') crlf++;\n\t\telse lf++;\n\t\tlines.push({ text: text.slice(start, match.index), end });\n\t\tstart = match.index + end.length;\n\t}\n\tif (start < text.length) lines.push({ text: text.slice(start), end: '' });\n\treturn { lines, eol: crlf > lf ? '\\r\\n' : '\\n', finalNewline: text.endsWith('\\n') };\n}\n\nexport function textsOf(lines: Line[]): string[] {\n\treturn lines.map(line => line.text);\n}\n\n/**\n * A line an edit adds, with the ending of the line before it, so it reads like its neighbours;\n * the text's usual ending when there is none.\n */\nexport function addedLine(text: string, before: Line | undefined, eol: TextLines['eol']): Line {\n\t// A line with no ending, the last one, lends none.\n\tconst end = before?.end ?? '';\n\treturn { text, end: end === '' ? eol : end };\n}\n\n/**\n * The text again. A line that gained a line after it gets an ending if it had none; the last line\n * ends as the text's last line did, with a newline or without.\n */\nexport function joinLines({ lines, eol, finalNewline }: TextLines): string {\n\treturn lines\n\t\t.map((line, index) => {\n\t\t\tif (index < lines.length - 1) return line.text + (line.end || eol);\n\t\t\treturn line.text + (finalNewline ? line.end || eol : '');\n\t\t})\n\t\t.join('');\n}\n"]}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Applies the hunks of a V4A `update_file` diff to `content`, in order, each after the one before
3
+ * it. A hunk with no anchor must match exactly once in the part of the file it searches, or
4
+ * nothing is changed: a hunk is never applied where the model did not mean it. After an anchor,
5
+ * it is the first match: the anchor already says where. Each line the hunk does not change is
6
+ * written back as it was, ending included, and an added line ends like the line before it.
7
+ *
8
+ * @throws {EditRefusedError} naming the hunk, what it expected and what the file has near it.
9
+ */
10
+ export declare function applyUpdate(content: string, diff: string, path: string): string;
11
+ /**
12
+ * The content of a file a V4A `create_file` diff makes: every line starts with `+`. It ends with
13
+ * a newline, as a text file does.
14
+ *
15
+ * @throws {EditRefusedError} when a line is not an added one.
16
+ */
17
+ export declare function createdContent(diff: string, path: string): string;
@@ -0,0 +1,236 @@
1
+ import { EditRefusedError } from "./fileEdits.js";
2
+ import { addedLine, joinLines, splitLines, textsOf } from "./textLines.js";
3
+ // A patch is a few changes, not a file: one far larger is more likely a mistake.
4
+ const maxDiffBytes = 512 * 1024;
5
+ const maxHunks = 500;
6
+ // What an error quotes of a hunk or of the file, to fix the call without flooding the context.
7
+ const quotedLines = 8;
8
+ /** The lines a hunk expects in the file: the ones it keeps and the ones it removes. */
9
+ function expected(hunk) {
10
+ return hunk.steps.filter(step => step.kind !== 'add').map(step => step.text);
11
+ }
12
+ function changes(hunk) {
13
+ return hunk.steps.some(step => step.kind !== 'keep');
14
+ }
15
+ function checkSize(diff, path) {
16
+ if (Buffer.byteLength(diff) > maxDiffBytes) {
17
+ throw new EditRefusedError(`The patch for "${path}" is larger than ${String(maxDiffBytes / 1024)} KB; send smaller patches`);
18
+ }
19
+ }
20
+ /** The diff's lines, without the newline that ends the last one. */
21
+ function diffLines(diff) {
22
+ const lines = diff.split('\n').map(line => (line.endsWith('\r') ? line.slice(0, -1) : line));
23
+ if (lines.at(-1) === '')
24
+ lines.pop();
25
+ return lines;
26
+ }
27
+ function parseHunks(diff, path) {
28
+ const hunks = [];
29
+ let current;
30
+ for (const [index, line] of diffLines(diff).entries()) {
31
+ if (line.startsWith('@@')) {
32
+ const anchor = line.slice(2).trim();
33
+ // Two `@@` lines in a row name an anchor and then one inside it.
34
+ if (current?.steps.length === 0 && !current.atEnd) {
35
+ current.anchor = [current.anchor, anchor].filter(Boolean).join('\n') || undefined;
36
+ continue;
37
+ }
38
+ current = { anchor: anchor || undefined, steps: [], atEnd: false };
39
+ hunks.push(current);
40
+ continue;
41
+ }
42
+ if (line === '*** End of File') {
43
+ if (current)
44
+ current.atEnd = true;
45
+ continue;
46
+ }
47
+ if (current?.atEnd) {
48
+ throw new EditRefusedError(`Line ${String(index + 1)} of the patch for "${path}" follows "*** End of File", which ends its hunk; start another hunk with "@@"`);
49
+ }
50
+ // A diff may open with its first change, without `@@`.
51
+ if (!current) {
52
+ current = { anchor: undefined, steps: [], atEnd: false };
53
+ hunks.push(current);
54
+ }
55
+ const marker = line[0];
56
+ const text = line.slice(1);
57
+ // An empty line is an empty context line whose space was trimmed.
58
+ if (line === '' || marker === ' ') {
59
+ current.steps.push({ kind: 'keep', text });
60
+ }
61
+ else if (marker === '-') {
62
+ current.steps.push({ kind: 'remove', text });
63
+ }
64
+ else if (marker === '+') {
65
+ current.steps.push({ kind: 'add', text });
66
+ }
67
+ else {
68
+ throw new EditRefusedError(`Line ${String(index + 1)} of the patch for "${path}" starts with "${marker ?? ''}"; each line of a hunk starts with " ", "-" or "+"`);
69
+ }
70
+ }
71
+ const changing = hunks.filter(changes);
72
+ if (changing.length === 0) {
73
+ throw new EditRefusedError(`The patch for "${path}" changes nothing`);
74
+ }
75
+ if (changing.length > maxHunks) {
76
+ throw new EditRefusedError(`The patch for "${path}" has more than ${String(maxHunks)} hunks; send smaller patches`);
77
+ }
78
+ return changing;
79
+ }
80
+ // Each pass forgives a little more: the model may copy a line with its trailing spaces lost.
81
+ const passes = [
82
+ line => line,
83
+ line => line.trimEnd(),
84
+ line => line.trim(),
85
+ ];
86
+ /**
87
+ * Where `pattern` starts in `lines`, at or after `from`, by the first pass that finds it. `null`
88
+ * when no pass does. With `once`, the first pass that matches must find one place, or the number
89
+ * of places is returned; without it, the first place is taken.
90
+ */
91
+ function seek(lines, pattern, { from, atEnd, once }) {
92
+ const last = lines.length - pattern.length;
93
+ for (const normalize of passes) {
94
+ const wanted = pattern.map(normalize);
95
+ const matchesAt = (start) => wanted.every((line, offset) => normalize(lines[start + offset] ?? '') === line);
96
+ if (atEnd) {
97
+ if (last >= from && matchesAt(last))
98
+ return last;
99
+ continue;
100
+ }
101
+ const found = [];
102
+ for (let start = from; start <= last; start++) {
103
+ if (!matchesAt(start))
104
+ continue;
105
+ if (!once)
106
+ return start;
107
+ found.push(start);
108
+ }
109
+ if (found.length === 1)
110
+ return found[0] ?? null;
111
+ if (found.length > 1)
112
+ return { places: found.length };
113
+ }
114
+ return null;
115
+ }
116
+ function quote(lines) {
117
+ const shown = lines.slice(0, quotedLines).map(line => ` ${line}`);
118
+ if (lines.length > quotedLines)
119
+ shown.push(` … ${String(lines.length - quotedLines)} more`);
120
+ return shown.join('\n');
121
+ }
122
+ /** The real lines near where a hunk's first line appears, so the model can copy them. */
123
+ function near(lines, hunk) {
124
+ const first = expected(hunk)
125
+ .find(line => line.trim() !== '')
126
+ ?.trim();
127
+ const at = first === undefined ? -1 : lines.findIndex(line => line.trim() === first);
128
+ if (at === -1)
129
+ return '';
130
+ const from = Math.max(0, at - 1);
131
+ const shown = lines
132
+ .slice(from, from + quotedLines)
133
+ .map((line, offset) => ` ${String(from + offset + 1)}: ${line}`);
134
+ return `\nThe file has, near line ${String(at + 1)}:\n${shown.join('\n')}`;
135
+ }
136
+ /**
137
+ * Where a hunk's anchor lines are, one after another, so its change is found after them. The
138
+ * first must appear once; each one after it is the first that follows the one before, as V4A
139
+ * reads `@@ class B:` then `@@ def __init__(self):`.
140
+ */
141
+ function seekAnchor(lines, hunk, from, path, n) {
142
+ let cursor = from;
143
+ for (const [index, anchor] of (hunk.anchor?.split('\n') ?? []).entries()) {
144
+ const at = seek(lines, [anchor], { from: cursor, atEnd: false, once: index === 0 });
145
+ if (typeof at !== 'number') {
146
+ throw new EditRefusedError(at === null
147
+ ? `Hunk ${String(n)} of the patch for "${path}" names "@@ ${anchor}", which is not in the file after the hunks before it`
148
+ : `Hunk ${String(n)} of the patch for "${path}" names "@@ ${anchor}", which appears ${String(at.places)} times; name a line that appears once, or put an "@@" line before it naming what holds it`);
149
+ }
150
+ cursor = at + 1;
151
+ }
152
+ return cursor;
153
+ }
154
+ /**
155
+ * Applies the hunks of a V4A `update_file` diff to `content`, in order, each after the one before
156
+ * it. A hunk with no anchor must match exactly once in the part of the file it searches, or
157
+ * nothing is changed: a hunk is never applied where the model did not mean it. After an anchor,
158
+ * it is the first match: the anchor already says where. Each line the hunk does not change is
159
+ * written back as it was, ending included, and an added line ends like the line before it.
160
+ *
161
+ * @throws {EditRefusedError} naming the hunk, what it expected and what the file has near it.
162
+ */
163
+ export function applyUpdate(content, diff, path) {
164
+ checkSize(diff, path);
165
+ const text = splitLines(content);
166
+ const lines = [...text.lines];
167
+ let cursor = 0;
168
+ for (const [index, hunk] of parseHunks(diff, path).entries()) {
169
+ const n = index + 1;
170
+ const texts = textsOf(lines);
171
+ const from = seekAnchor(texts, hunk, cursor, path, n);
172
+ const old = expected(hunk);
173
+ if (old.length === 0) {
174
+ // Only added lines: after the anchor, or at the end.
175
+ const at = hunk.anchor === undefined || hunk.atEnd ? lines.length : from;
176
+ const added = [];
177
+ for (const step of hunk.steps) {
178
+ added.push(addedLine(step.text, added.at(-1) ?? lines[at - 1], text.eol));
179
+ }
180
+ lines.splice(at, 0, ...added);
181
+ cursor = at + added.length;
182
+ continue;
183
+ }
184
+ const at = seek(texts, old, { from, atEnd: hunk.atEnd, once: hunk.anchor === undefined });
185
+ if (typeof at !== 'number') {
186
+ throw new EditRefusedError(at === null
187
+ ? `Hunk ${String(n)} of the patch for "${path}" does not match the file. It expected:\n${quote(old)}${near(texts, hunk)}`
188
+ : `Hunk ${String(n)} of the patch for "${path}" matches ${String(at.places)} places; add an "@@" line naming the function or more context`);
189
+ }
190
+ // A kept line stays as the file has it, even where the patch copied it with a space lost. An
191
+ // added line that takes a removed one's place takes its ending too.
192
+ const replacement = [];
193
+ const replaced = [];
194
+ let offset = 0;
195
+ for (const step of hunk.steps) {
196
+ if (step.kind === 'remove') {
197
+ const removed = lines[at + offset++];
198
+ if (removed)
199
+ replaced.push(removed);
200
+ continue;
201
+ }
202
+ if (step.kind === 'keep') {
203
+ replaced.length = 0;
204
+ const kept = lines[at + offset++];
205
+ replacement.push(kept ?? addedLine(step.text, replacement.at(-1), text.eol));
206
+ continue;
207
+ }
208
+ const before = replaced.shift() ?? replacement.at(-1) ?? lines[at - 1];
209
+ replacement.push(addedLine(step.text, before, text.eol));
210
+ }
211
+ lines.splice(at, old.length, ...replacement);
212
+ cursor = at + replacement.length;
213
+ }
214
+ return joinLines({ ...text, lines, finalNewline: text.finalNewline || text.lines.length === 0 });
215
+ }
216
+ /**
217
+ * The content of a file a V4A `create_file` diff makes: every line starts with `+`. It ends with
218
+ * a newline, as a text file does.
219
+ *
220
+ * @throws {EditRefusedError} when a line is not an added one.
221
+ */
222
+ export function createdContent(diff, path) {
223
+ checkSize(diff, path);
224
+ const lines = diffLines(diff).filter(line => line !== '*** End of File');
225
+ for (const [index, line] of lines.entries()) {
226
+ if (!line.startsWith('+')) {
227
+ throw new EditRefusedError(`Line ${String(index + 1)} of the patch creating "${path}" does not start with "+"; every line of a new file does`);
228
+ }
229
+ }
230
+ return joinLines({
231
+ lines: lines.map(line => ({ text: line.slice(1), end: '\n' })),
232
+ eol: '\n',
233
+ finalNewline: true,
234
+ });
235
+ }
236
+ //# sourceMappingURL=v4aPatch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"v4aPatch.js","sourceRoot":"","sources":["../../../src/tools/domain/v4aPatch.ts"],"names":[],"mappings":"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","sourcesContent":["import { EditRefusedError } from './fileEdits.ts';\nimport { addedLine, joinLines, type Line, splitLines, textsOf } from './textLines.ts';\n\n// A patch is a few changes, not a file: one far larger is more likely a mistake.\nconst maxDiffBytes = 512 * 1024;\nconst maxHunks = 500;\n// What an error quotes of a hunk or of the file, to fix the call without flooding the context.\nconst quotedLines = 8;\n\n/** One line of a hunk: kept as the file has it, removed, or added. */\ninterface Step {\n\tkind: 'keep' | 'remove' | 'add';\n\ttext: string;\n}\n\n/** One change of a V4A diff: the lines it expects, and the lines it puts in their place. */\ninterface Hunk {\n\t/** The text after `@@`, such as a function's first line, found before the change. */\n\tanchor: string | undefined;\n\tsteps: Step[];\n\t/** `*** End of File`: the change is at the end of the file. */\n\tatEnd: boolean;\n}\n\n/** The lines a hunk expects in the file: the ones it keeps and the ones it removes. */\nfunction expected(hunk: Hunk): string[] {\n\treturn hunk.steps.filter(step => step.kind !== 'add').map(step => step.text);\n}\n\nfunction changes(hunk: Hunk): boolean {\n\treturn hunk.steps.some(step => step.kind !== 'keep');\n}\n\nfunction checkSize(diff: string, path: string): void {\n\tif (Buffer.byteLength(diff) > maxDiffBytes) {\n\t\tthrow new EditRefusedError(\n\t\t\t`The patch for \"${path}\" is larger than ${String(maxDiffBytes / 1024)} KB; send smaller patches`,\n\t\t);\n\t}\n}\n\n/** The diff's lines, without the newline that ends the last one. */\nfunction diffLines(diff: string): string[] {\n\tconst lines = diff.split('\\n').map(line => (line.endsWith('\\r') ? line.slice(0, -1) : line));\n\tif (lines.at(-1) === '') lines.pop();\n\treturn lines;\n}\n\nfunction parseHunks(diff: string, path: string): Hunk[] {\n\tconst hunks: Hunk[] = [];\n\tlet current: Hunk | undefined;\n\tfor (const [index, line] of diffLines(diff).entries()) {\n\t\tif (line.startsWith('@@')) {\n\t\t\tconst anchor = line.slice(2).trim();\n\t\t\t// Two `@@` lines in a row name an anchor and then one inside it.\n\t\t\tif (current?.steps.length === 0 && !current.atEnd) {\n\t\t\t\tcurrent.anchor = [current.anchor, anchor].filter(Boolean).join('\\n') || undefined;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tcurrent = { anchor: anchor || undefined, steps: [], atEnd: false };\n\t\t\thunks.push(current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (line === '*** End of File') {\n\t\t\tif (current) current.atEnd = true;\n\t\t\tcontinue;\n\t\t}\n\t\tif (current?.atEnd) {\n\t\t\tthrow new EditRefusedError(\n\t\t\t\t`Line ${String(index + 1)} of the patch for \"${path}\" follows \"*** End of File\", which ends its hunk; start another hunk with \"@@\"`,\n\t\t\t);\n\t\t}\n\t\t// A diff may open with its first change, without `@@`.\n\t\tif (!current) {\n\t\t\tcurrent = { anchor: undefined, steps: [], atEnd: false };\n\t\t\thunks.push(current);\n\t\t}\n\t\tconst marker = line[0];\n\t\tconst text = line.slice(1);\n\t\t// An empty line is an empty context line whose space was trimmed.\n\t\tif (line === '' || marker === ' ') {\n\t\t\tcurrent.steps.push({ kind: 'keep', text });\n\t\t} else if (marker === '-') {\n\t\t\tcurrent.steps.push({ kind: 'remove', text });\n\t\t} else if (marker === '+') {\n\t\t\tcurrent.steps.push({ kind: 'add', text });\n\t\t} else {\n\t\t\tthrow new EditRefusedError(\n\t\t\t\t`Line ${String(index + 1)} of the patch for \"${path}\" starts with \"${marker ?? ''}\"; each line of a hunk starts with \" \", \"-\" or \"+\"`,\n\t\t\t);\n\t\t}\n\t}\n\tconst changing = hunks.filter(changes);\n\tif (changing.length === 0) {\n\t\tthrow new EditRefusedError(`The patch for \"${path}\" changes nothing`);\n\t}\n\tif (changing.length > maxHunks) {\n\t\tthrow new EditRefusedError(\n\t\t\t`The patch for \"${path}\" has more than ${String(maxHunks)} hunks; send smaller patches`,\n\t\t);\n\t}\n\treturn changing;\n}\n\n// Each pass forgives a little more: the model may copy a line with its trailing spaces lost.\nconst passes: ((line: string) => string)[] = [\n\tline => line,\n\tline => line.trimEnd(),\n\tline => line.trim(),\n];\n\n/**\n * Where `pattern` starts in `lines`, at or after `from`, by the first pass that finds it. `null`\n * when no pass does. With `once`, the first pass that matches must find one place, or the number\n * of places is returned; without it, the first place is taken.\n */\nfunction seek(\n\tlines: string[],\n\tpattern: string[],\n\t{ from, atEnd, once }: { from: number; atEnd: boolean; once: boolean },\n): number | null | { places: number } {\n\tconst last = lines.length - pattern.length;\n\tfor (const normalize of passes) {\n\t\tconst wanted = pattern.map(normalize);\n\t\tconst matchesAt = (start: number) =>\n\t\t\twanted.every((line, offset) => normalize(lines[start + offset] ?? '') === line);\n\t\tif (atEnd) {\n\t\t\tif (last >= from && matchesAt(last)) return last;\n\t\t\tcontinue;\n\t\t}\n\t\tconst found: number[] = [];\n\t\tfor (let start = from; start <= last; start++) {\n\t\t\tif (!matchesAt(start)) continue;\n\t\t\tif (!once) return start;\n\t\t\tfound.push(start);\n\t\t}\n\t\tif (found.length === 1) return found[0] ?? null;\n\t\tif (found.length > 1) return { places: found.length };\n\t}\n\treturn null;\n}\n\nfunction quote(lines: string[]): string {\n\tconst shown = lines.slice(0, quotedLines).map(line => ` ${line}`);\n\tif (lines.length > quotedLines) shown.push(` … ${String(lines.length - quotedLines)} more`);\n\treturn shown.join('\\n');\n}\n\n/** The real lines near where a hunk's first line appears, so the model can copy them. */\nfunction near(lines: string[], hunk: Hunk): string {\n\tconst first = expected(hunk)\n\t\t.find(line => line.trim() !== '')\n\t\t?.trim();\n\tconst at = first === undefined ? -1 : lines.findIndex(line => line.trim() === first);\n\tif (at === -1) return '';\n\tconst from = Math.max(0, at - 1);\n\tconst shown = lines\n\t\t.slice(from, from + quotedLines)\n\t\t.map((line, offset) => ` ${String(from + offset + 1)}: ${line}`);\n\treturn `\\nThe file has, near line ${String(at + 1)}:\\n${shown.join('\\n')}`;\n}\n\n/**\n * Where a hunk's anchor lines are, one after another, so its change is found after them. The\n * first must appear once; each one after it is the first that follows the one before, as V4A\n * reads `@@ class B:` then `@@ def __init__(self):`.\n */\nfunction seekAnchor(lines: string[], hunk: Hunk, from: number, path: string, n: number): number {\n\tlet cursor = from;\n\tfor (const [index, anchor] of (hunk.anchor?.split('\\n') ?? []).entries()) {\n\t\tconst at = seek(lines, [anchor], { from: cursor, atEnd: false, once: index === 0 });\n\t\tif (typeof at !== 'number') {\n\t\t\tthrow new EditRefusedError(\n\t\t\t\tat === null\n\t\t\t\t\t? `Hunk ${String(n)} of the patch for \"${path}\" names \"@@ ${anchor}\", which is not in the file after the hunks before it`\n\t\t\t\t\t: `Hunk ${String(n)} of the patch for \"${path}\" names \"@@ ${anchor}\", which appears ${String(at.places)} times; name a line that appears once, or put an \"@@\" line before it naming what holds it`,\n\t\t\t);\n\t\t}\n\t\tcursor = at + 1;\n\t}\n\treturn cursor;\n}\n\n/**\n * Applies the hunks of a V4A `update_file` diff to `content`, in order, each after the one before\n * it. A hunk with no anchor must match exactly once in the part of the file it searches, or\n * nothing is changed: a hunk is never applied where the model did not mean it. After an anchor,\n * it is the first match: the anchor already says where. Each line the hunk does not change is\n * written back as it was, ending included, and an added line ends like the line before it.\n *\n * @throws {EditRefusedError} naming the hunk, what it expected and what the file has near it.\n */\nexport function applyUpdate(content: string, diff: string, path: string): string {\n\tcheckSize(diff, path);\n\tconst text = splitLines(content);\n\tconst lines: Line[] = [...text.lines];\n\tlet cursor = 0;\n\tfor (const [index, hunk] of parseHunks(diff, path).entries()) {\n\t\tconst n = index + 1;\n\t\tconst texts = textsOf(lines);\n\t\tconst from = seekAnchor(texts, hunk, cursor, path, n);\n\t\tconst old = expected(hunk);\n\t\tif (old.length === 0) {\n\t\t\t// Only added lines: after the anchor, or at the end.\n\t\t\tconst at = hunk.anchor === undefined || hunk.atEnd ? lines.length : from;\n\t\t\tconst added: Line[] = [];\n\t\t\tfor (const step of hunk.steps) {\n\t\t\t\tadded.push(addedLine(step.text, added.at(-1) ?? lines[at - 1], text.eol));\n\t\t\t}\n\t\t\tlines.splice(at, 0, ...added);\n\t\t\tcursor = at + added.length;\n\t\t\tcontinue;\n\t\t}\n\t\tconst at = seek(texts, old, { from, atEnd: hunk.atEnd, once: hunk.anchor === undefined });\n\t\tif (typeof at !== 'number') {\n\t\t\tthrow new EditRefusedError(\n\t\t\t\tat === null\n\t\t\t\t\t? `Hunk ${String(n)} of the patch for \"${path}\" does not match the file. It expected:\\n${quote(old)}${near(texts, hunk)}`\n\t\t\t\t\t: `Hunk ${String(n)} of the patch for \"${path}\" matches ${String(at.places)} places; add an \"@@\" line naming the function or more context`,\n\t\t\t);\n\t\t}\n\t\t// A kept line stays as the file has it, even where the patch copied it with a space lost. An\n\t\t// added line that takes a removed one's place takes its ending too.\n\t\tconst replacement: Line[] = [];\n\t\tconst replaced: Line[] = [];\n\t\tlet offset = 0;\n\t\tfor (const step of hunk.steps) {\n\t\t\tif (step.kind === 'remove') {\n\t\t\t\tconst removed = lines[at + offset++];\n\t\t\t\tif (removed) replaced.push(removed);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (step.kind === 'keep') {\n\t\t\t\treplaced.length = 0;\n\t\t\t\tconst kept = lines[at + offset++];\n\t\t\t\treplacement.push(kept ?? addedLine(step.text, replacement.at(-1), text.eol));\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst before = replaced.shift() ?? replacement.at(-1) ?? lines[at - 1];\n\t\t\treplacement.push(addedLine(step.text, before, text.eol));\n\t\t}\n\t\tlines.splice(at, old.length, ...replacement);\n\t\tcursor = at + replacement.length;\n\t}\n\treturn joinLines({ ...text, lines, finalNewline: text.finalNewline || text.lines.length === 0 });\n}\n\n/**\n * The content of a file a V4A `create_file` diff makes: every line starts with `+`. It ends with\n * a newline, as a text file does.\n *\n * @throws {EditRefusedError} when a line is not an added one.\n */\nexport function createdContent(diff: string, path: string): string {\n\tcheckSize(diff, path);\n\tconst lines = diffLines(diff).filter(line => line !== '*** End of File');\n\tfor (const [index, line] of lines.entries()) {\n\t\tif (!line.startsWith('+')) {\n\t\t\tthrow new EditRefusedError(\n\t\t\t\t`Line ${String(index + 1)} of the patch creating \"${path}\" does not start with \"+\"; every line of a new file does`,\n\t\t\t);\n\t\t}\n\t}\n\treturn joinLines({\n\t\tlines: lines.map(line => ({ text: line.slice(1), end: '\\n' })),\n\t\teol: '\\n',\n\t\tfinalNewline: true,\n\t});\n}\n"]}
@@ -13,12 +13,21 @@ import type { TextMatch, Workspace } from '../domain/workspace.ts';
13
13
  export declare abstract class BoundedWorkspace implements Workspace {
14
14
  protected readonly root: string;
15
15
  private readonly timeoutMs;
16
- constructor({ root, timeoutMs }: {
16
+ private readonly hidden;
17
+ constructor({ root, timeoutMs, hidden, }: {
17
18
  root: string;
18
19
  timeoutMs?: number;
20
+ /**
21
+ * Files to treat as absent, by their path relative to the root, such as the secrets an
22
+ * access policy closes. Applied before anything is counted or stored, so no count, limit
23
+ * or error can tell that one matched.
24
+ */
25
+ hidden?: (path: string) => boolean;
19
26
  });
20
27
  /** Every file the program does not ignore, relative to the root, in any order. */
21
- protected abstract listCandidates(signal: AbortSignal): Promise<string[]>;
28
+ protected abstract listUnignored(signal: AbortSignal): Promise<string[]>;
29
+ /** Every file it may show: what the program does not ignore, less what is hidden. */
30
+ private listCandidates;
22
31
  /**
23
32
  * Hands `keep` every line matching `pattern` in a file the program does not ignore, in any
24
33
  * order, as the program prints it. Nothing is held here: `keep` decides what to store.
@@ -29,9 +29,17 @@ function compareText(a, b) {
29
29
  export class BoundedWorkspace {
30
30
  root;
31
31
  timeoutMs;
32
- constructor({ root, timeoutMs = defaultTimeoutMs }) {
32
+ hidden;
33
+ constructor({ root, timeoutMs = defaultTimeoutMs, hidden, }) {
33
34
  this.root = root;
34
35
  this.timeoutMs = timeoutMs;
36
+ this.hidden = hidden;
37
+ }
38
+ /** Every file it may show: what the program does not ignore, less what is hidden. */
39
+ async listCandidates(signal) {
40
+ const files = await this.listUnignored(signal);
41
+ const { hidden } = this;
42
+ return hidden ? files.filter(path => !hidden(path)) : files;
35
43
  }
36
44
  listFiles(query, signal) {
37
45
  return this.withTimeout(signal, async (bounded) => {
@@ -1 +1 @@
1
- {"version":3,"file":"boundedWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/boundedWorkspace.ts"],"names":[],"mappings":"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","sourcesContent":["import { createReadStream } from 'node:fs';\nimport { lstat, open } from 'node:fs/promises';\nimport { isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';\nimport { createInterface } from 'node:readline';\nimport type { TextMatch, Workspace } from '../domain/workspace.ts';\n\n// A NUL byte this early means a binary file, as git and ripgrep both judge it.\nconst binaryProbeBytes = 8000;\n\n// What a search holds while it sorts. A minified file can put a whole bundle on one line, and\n// a careless pattern can match every line of a repository.\nconst maxStoredMatches = 50_000;\nconst maxStoredLineLength = 1_000;\n\n// Far beyond what ripgrep needs on a large repository, so only a runaway search reaches it,\n// such as a pattern that backtracks without end in git's Perl engine.\nconst defaultTimeoutMs = 30_000;\n\nfunction compareText(a: string, b: string): number {\n\t// Plain code-unit order: `localeCompare` would sort differently on every machine.\n\treturn a < b ? -1 : a > b ? 1 : 0;\n}\n\n/**\n * Everything both implementations share: the boundary and the shape of every answer. A\n * subclass only says which files are visible and where a pattern matches, across the whole\n * root; the scope a model asks for is applied here, to those results, and never handed to the\n * program underneath. ripgrep stops honouring `.gitignore` for a path it is given explicitly,\n * so passing the model's scope down is how a secret would leak. See \"`.gitignore` is the\n * boundary of what an agent reads\" in decisions.md.\n *\n * Every operation also stops after `timeoutMs`. A timeout is a failure the model reads and\n * narrows its query from, not a cancellation: only the run's own signal cancels the run.\n */\nexport abstract class BoundedWorkspace implements Workspace {\n\tprotected readonly root: string;\n\tprivate readonly timeoutMs: number;\n\n\tconstructor({ root, timeoutMs = defaultTimeoutMs }: { root: string; timeoutMs?: number }) {\n\t\tthis.root = root;\n\t\tthis.timeoutMs = timeoutMs;\n\t}\n\n\t/** Every file the program does not ignore, relative to the root, in any order. */\n\tprotected abstract listCandidates(signal: AbortSignal): Promise<string[]>;\n\n\t/**\n\t * Hands `keep` every line matching `pattern` in a file the program does not ignore, in any\n\t * order, as the program prints it. Nothing is held here: `keep` decides what to store.\n\t */\n\tprotected abstract findMatches(\n\t\tpattern: string,\n\t\tignoreCase: boolean,\n\t\tsignal: AbortSignal,\n\t\tkeep: (match: TextMatch) => void,\n\t): Promise<void>;\n\n\tlistFiles(\n\t\tquery: { path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ files: string[]; total: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst inScope = [...new Set(await this.listCandidates(bounded))].filter(path =>\n\t\t\t\tthis.inScope(path, query),\n\t\t\t);\n\t\t\tconst files = (await this.regularFiles(inScope)).sort(compareText);\n\n\t\t\treturn {\n\t\t\t\tfiles: files.slice(0, query.limit),\n\t\t\t\ttotal: files.length,\n\t\t\t\ttruncated: files.length > query.limit,\n\t\t\t};\n\t\t});\n\t}\n\n\tsearchText(\n\t\tquery: { pattern: string; ignoreCase: boolean; path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ matches: TextMatch[]; total: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst listed = new Set(await this.listCandidates(bounded));\n\t\t\t// The scope applies as the output arrives, so narrowing a search is what makes it\n\t\t\t// lighter: matches outside it are never stored, however many the program prints.\n\t\t\tconst kept: TextMatch[] = [];\n\t\t\tawait this.findMatches(query.pattern, query.ignoreCase, bounded, match => {\n\t\t\t\tif (!listed.has(match.path) || !this.inScope(match.path, query)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (kept.length === maxStoredMatches) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`More than ${String(maxStoredMatches)} lines match; narrow the pattern, path or glob`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tkept.push({ ...match, text: match.text.slice(0, maxStoredLineLength) });\n\t\t\t});\n\n\t\t\t// Only the files that matched are checked on disk, not the whole repository.\n\t\t\tconst regular = new Set(await this.regularFiles([...new Set(kept.map(match => match.path))]));\n\t\t\tconst matches = kept\n\t\t\t\t.filter(match => regular.has(match.path))\n\t\t\t\t.sort((a, b) => compareText(a.path, b.path) || a.line - b.line);\n\n\t\t\treturn {\n\t\t\t\tmatches: matches.slice(0, query.limit),\n\t\t\t\ttotal: matches.length,\n\t\t\t\ttruncated: matches.length > query.limit,\n\t\t\t};\n\t\t});\n\t}\n\n\treadFile(\n\t\tquery: { path: string; fromLine: number; lineCount: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ lines: string[]; totalLines: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst path = this.toRelative(query.path);\n\t\t\tconst visible =\n\t\t\t\tpath !== undefined &&\n\t\t\t\t(await this.listCandidates(bounded)).includes(path) &&\n\t\t\t\t(await this.regularFiles([path])).length === 1;\n\t\t\t// One answer for a missing file and an ignored one, or the model would learn the secret exists.\n\t\t\tif (!visible) {\n\t\t\t\tthrow new Error(`No such file: ${query.path}`);\n\t\t\t}\n\n\t\t\tconst absolute = join(this.root, path);\n\t\t\tif (await isBinary(absolute)) {\n\t\t\t\tthrow new Error(`Not a text file: ${query.path}`);\n\t\t\t}\n\n\t\t\t// Streamed, so a large file costs its length in lines, not in memory.\n\t\t\tconst reader = createInterface({\n\t\t\t\tinput: createReadStream(absolute, { encoding: 'utf8', signal: bounded }),\n\t\t\t\tcrlfDelay: Infinity,\n\t\t\t});\n\t\t\tconst last = query.fromLine + query.lineCount - 1;\n\t\t\tconst lines: string[] = [];\n\t\t\tlet totalLines = 0;\n\t\t\tfor await (const line of reader) {\n\t\t\t\ttotalLines++;\n\t\t\t\tif (totalLines >= query.fromLine && totalLines <= last) {\n\t\t\t\t\tlines.push(line);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn { lines, totalLines, truncated: totalLines > last };\n\t\t});\n\t}\n\n\t/**\n\t * Runs an operation under the run's signal and this workspace's time limit together. The\n\t * run's cancellation escapes unchanged; the time limit becomes an error the model can act on.\n\t */\n\tprivate async withTimeout<Result>(\n\t\tsignal: AbortSignal,\n\t\toperation: (bounded: AbortSignal) => Promise<Result>,\n\t): Promise<Result> {\n\t\tsignal.throwIfAborted();\n\t\tconst timeout = AbortSignal.timeout(this.timeoutMs);\n\t\ttry {\n\t\t\treturn await operation(AbortSignal.any([signal, timeout]));\n\t\t} catch (error) {\n\t\t\tsignal.throwIfAborted();\n\t\t\tif (timeout.aborted) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`The operation took longer than ${String(this.timeoutMs / 1000)} s; narrow the pattern, path or glob`,\n\t\t\t\t\t{ cause: error },\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/** Regular files only: a symlink could point out of the root, and a deleted file is gone. */\n\tprivate async regularFiles(paths: string[]): Promise<string[]> {\n\t\tconst regular = await Promise.all(\n\t\t\tpaths.map(path =>\n\t\t\t\tlstat(join(this.root, path)).then(\n\t\t\t\t\tstats => stats.isFile(),\n\t\t\t\t\t() => false,\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\n\t\treturn paths.filter((_, index) => regular[index]);\n\t}\n\n\tprivate inScope(path: string, { path: scope, glob }: { path?: string; glob?: string }): boolean {\n\t\tif (scope !== undefined) {\n\t\t\tconst folder = this.toRelative(scope);\n\t\t\tif (folder === undefined) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (folder !== '' && path !== folder && !path.startsWith(`${folder}/`)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn glob === undefined || matchesGlob(path, glob);\n\t}\n\n\t/** A path relative to the root with `/`, '' for the root itself, or undefined when it leaves it. */\n\tprivate toRelative(path: string): string | undefined {\n\t\tconst relativePath = relative(this.root, resolve(this.root, path));\n\t\tif (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\treturn relativePath.split(sep).join('/');\n\t}\n}\n\nasync function isBinary(path: string): Promise<boolean> {\n\tconst file = await open(path);\n\ttry {\n\t\tconst probe = Buffer.alloc(binaryProbeBytes);\n\t\tconst { bytesRead } = await file.read(probe, 0, probe.length, 0);\n\t\treturn probe.subarray(0, bytesRead).includes(0);\n\t} finally {\n\t\tawait file.close();\n\t}\n}\n"]}
1
+ {"version":3,"file":"boundedWorkspace.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/boundedWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAClF,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGhD,+EAA+E;AAC/E,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAE9B,8FAA8F;AAC9F,2DAA2D;AAC3D,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAChC,MAAM,mBAAmB,GAAG,KAAK,CAAC;AAElC,4FAA4F;AAC5F,sEAAsE;AACtE,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEhC,SAAS,WAAW,CAAC,CAAS,EAAE,CAAS;IACxC,kFAAkF;IAClF,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACnC,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,OAAgB,gBAAgB;IAClB,IAAI,CAAS;IACf,SAAS,CAAS;IAClB,MAAM,CAA0C;IAEjE,YAAY,EACX,IAAI,EACJ,SAAS,GAAG,gBAAgB,EAC5B,MAAM,GAUN;QACA,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACtB,CAAC;IAKD,qFAAqF;IAC7E,KAAK,CAAC,cAAc,CAAC,MAAmB;QAC/C,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;QAC/C,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QACxB,OAAO,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IAC7D,CAAC;IAaD,SAAS,CACR,KAAsD,EACtD,MAAmB;QAEnB,OAAO,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,KAAK,EAAC,OAAO,EAAC,EAAE;YAC/C,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAC9E,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CACzB,CAAC;YACF,MAAM,KAAK,GAAG,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YAEnE,OAAO;gBACN,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC;gBAClC,KAAK,EAAE,KAAK,CAAC,MAAM;gBACnB,SAAS,EAAE,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,KAAK;aACrC,CAAC;QACH,CAAC,CAAC,CAAC;IACJ,CAAC;IAED,UAAU,CACT,KAA4F,EAC5F,MAAmB;QAEnB,OAAO,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,KAAK,EAAC,OAAO,EAAC,EAAE;YAC/C,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC;YAC3D,kFAAkF;YAClF,iFAAiF;YACjF,MAAM,IAAI,GAAgB,EAAE,CAAC;YAC7B,MAAM,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,UAAU,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE;gBACxE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,CAAC;oBACjE,OAAO;gBACR,CAAC;gBACD,IAAI,IAAI,CAAC,MAAM,KAAK,gBAAgB,EAAE,CAAC;oBACtC,MAAM,IAAI,KAAK,CACd,aAAa,MAAM,CAAC,gBAAgB,CAAC,gDAAgD,CACrF,CAAC;gBACH,CAAC;gBACD,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,mBAAmB,CAAC,EAAE,CAAC,CAAC;YACzE,CAAC,CAAC,CAAC;YAEH,6EAA6E;YAC7E,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC9F,MAAM,OAAO,GAAG,IAAI;iBAClB,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;iBACxC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC;YAEjE,OAAO;gBACN,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC;gBACtC,KAAK,EAAE,OAAO,CAAC,MAAM;gBACrB,SAAS,EAAE,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC,KAAK;aACvC,CAAC;QACH,CAAC,CAAC,CAAC;IACJ,CAAC;IAED,QAAQ,CACP,KAA4D,EAC5D,MAAmB;QAEnB,OAAO,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,KAAK,EAAC,OAAO,EAAC,EAAE;YAC/C,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACzC,MAAM,OAAO,GACZ,IAAI,KAAK,SAAS;gBAClB,CAAC,MAAM,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;gBACnD,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC;YAChD,gGAAgG;YAChG,IAAI,CAAC,OAAO,EAAE,CAAC;gBACd,MAAM,IAAI,KAAK,CAAC,iBAAiB,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;YAChD,CAAC;YAED,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YACvC,IAAI,MAAM,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC9B,MAAM,IAAI,KAAK,CAAC,oBAAoB,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;YACnD,CAAC;YAED,sEAAsE;YACtE,MAAM,MAAM,GAAG,eAAe,CAAC;gBAC9B,KAAK,EAAE,gBAAgB,CAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;gBACxE,SAAS,EAAE,QAAQ;aACnB,CAAC,CAAC;YACH,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,GAAG,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC;YAClD,MAAM,KAAK,GAAa,EAAE,CAAC;YAC3B,IAAI,UAAU,GAAG,CAAC,CAAC;YACnB,IAAI,KAAK,EAAE,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;gBACjC,UAAU,EAAE,CAAC;gBACb,IAAI,UAAU,IAAI,KAAK,CAAC,QAAQ,IAAI,UAAU,IAAI,IAAI,EAAE,CAAC;oBACxD,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAClB,CAAC;YACF,CAAC;YAED,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,GAAG,IAAI,EAAE,CAAC;QAC5D,CAAC,CAAC,CAAC;IACJ,CAAC;IAED;;;OAGG;IACK,KAAK,CAAC,WAAW,CACxB,MAAmB,EACnB,SAAoD;QAEpD,MAAM,CAAC,cAAc,EAAE,CAAC;QACxB,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACpD,IAAI,CAAC;YACJ,OAAO,MAAM,SAAS,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;QAC5D,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,MAAM,CAAC,cAAc,EAAE,CAAC;YACxB,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;gBACrB,MAAM,IAAI,KAAK,CACd,kCAAkC,MAAM,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,sCAAsC,EACrG,EAAE,KAAK,EAAE,KAAK,EAAE,CAChB,CAAC;YACH,CAAC;YACD,MAAM,KAAK,CAAC;QACb,CAAC;IACF,CAAC;IAED,6FAA6F;IACrF,KAAK,CAAC,YAAY,CAAC,KAAe;QACzC,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAChC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAChB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAChC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,EACvB,GAAG,EAAE,CAAC,KAAK,CACX,CACD,CACD,CAAC;QAEF,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;IACnD,CAAC;IAEO,OAAO,CAAC,IAAY,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAoC;QACpF,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;YACtC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;gBAC1B,OAAO,KAAK,CAAC;YACd,CAAC;YACD,IAAI,MAAM,KAAK,EAAE,IAAI,IAAI,KAAK,MAAM,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,MAAM,GAAG,CAAC,EAAE,CAAC;gBACxE,OAAO,KAAK,CAAC;YACd,CAAC;QACF,CAAC;QAED,OAAO,IAAI,KAAK,SAAS,IAAI,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACtD,CAAC;IAED,oGAAoG;IAC5F,UAAU,CAAC,IAAY;QAC9B,MAAM,YAAY,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;QACnE,IAAI,YAAY,KAAK,IAAI,IAAI,YAAY,CAAC,UAAU,CAAC,KAAK,GAAG,EAAE,CAAC,IAAI,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;YAC9F,OAAO,SAAS,CAAC;QAClB,CAAC;QAED,OAAO,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC1C,CAAC;CACD;AAED,KAAK,UAAU,QAAQ,CAAC,IAAY;IACnC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,CAAC;QACJ,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC7C,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACjE,OAAO,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IACjD,CAAC;YAAS,CAAC;QACV,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;IACpB,CAAC;AACF,CAAC","sourcesContent":["import { createReadStream } from 'node:fs';\nimport { lstat, open } from 'node:fs/promises';\nimport { isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';\nimport { createInterface } from 'node:readline';\nimport type { TextMatch, Workspace } from '../domain/workspace.ts';\n\n// A NUL byte this early means a binary file, as git and ripgrep both judge it.\nconst binaryProbeBytes = 8000;\n\n// What a search holds while it sorts. A minified file can put a whole bundle on one line, and\n// a careless pattern can match every line of a repository.\nconst maxStoredMatches = 50_000;\nconst maxStoredLineLength = 1_000;\n\n// Far beyond what ripgrep needs on a large repository, so only a runaway search reaches it,\n// such as a pattern that backtracks without end in git's Perl engine.\nconst defaultTimeoutMs = 30_000;\n\nfunction compareText(a: string, b: string): number {\n\t// Plain code-unit order: `localeCompare` would sort differently on every machine.\n\treturn a < b ? -1 : a > b ? 1 : 0;\n}\n\n/**\n * Everything both implementations share: the boundary and the shape of every answer. A\n * subclass only says which files are visible and where a pattern matches, across the whole\n * root; the scope a model asks for is applied here, to those results, and never handed to the\n * program underneath. ripgrep stops honouring `.gitignore` for a path it is given explicitly,\n * so passing the model's scope down is how a secret would leak. See \"`.gitignore` is the\n * boundary of what an agent reads\" in decisions.md.\n *\n * Every operation also stops after `timeoutMs`. A timeout is a failure the model reads and\n * narrows its query from, not a cancellation: only the run's own signal cancels the run.\n */\nexport abstract class BoundedWorkspace implements Workspace {\n\tprotected readonly root: string;\n\tprivate readonly timeoutMs: number;\n\tprivate readonly hidden: ((path: string) => boolean) | undefined;\n\n\tconstructor({\n\t\troot,\n\t\ttimeoutMs = defaultTimeoutMs,\n\t\thidden,\n\t}: {\n\t\troot: string;\n\t\ttimeoutMs?: number;\n\t\t/**\n\t\t * Files to treat as absent, by their path relative to the root, such as the secrets an\n\t\t * access policy closes. Applied before anything is counted or stored, so no count, limit\n\t\t * or error can tell that one matched.\n\t\t */\n\t\thidden?: (path: string) => boolean;\n\t}) {\n\t\tthis.root = root;\n\t\tthis.timeoutMs = timeoutMs;\n\t\tthis.hidden = hidden;\n\t}\n\n\t/** Every file the program does not ignore, relative to the root, in any order. */\n\tprotected abstract listUnignored(signal: AbortSignal): Promise<string[]>;\n\n\t/** Every file it may show: what the program does not ignore, less what is hidden. */\n\tprivate async listCandidates(signal: AbortSignal): Promise<string[]> {\n\t\tconst files = await this.listUnignored(signal);\n\t\tconst { hidden } = this;\n\t\treturn hidden ? files.filter(path => !hidden(path)) : files;\n\t}\n\n\t/**\n\t * Hands `keep` every line matching `pattern` in a file the program does not ignore, in any\n\t * order, as the program prints it. Nothing is held here: `keep` decides what to store.\n\t */\n\tprotected abstract findMatches(\n\t\tpattern: string,\n\t\tignoreCase: boolean,\n\t\tsignal: AbortSignal,\n\t\tkeep: (match: TextMatch) => void,\n\t): Promise<void>;\n\n\tlistFiles(\n\t\tquery: { path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ files: string[]; total: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst inScope = [...new Set(await this.listCandidates(bounded))].filter(path =>\n\t\t\t\tthis.inScope(path, query),\n\t\t\t);\n\t\t\tconst files = (await this.regularFiles(inScope)).sort(compareText);\n\n\t\t\treturn {\n\t\t\t\tfiles: files.slice(0, query.limit),\n\t\t\t\ttotal: files.length,\n\t\t\t\ttruncated: files.length > query.limit,\n\t\t\t};\n\t\t});\n\t}\n\n\tsearchText(\n\t\tquery: { pattern: string; ignoreCase: boolean; path?: string; glob?: string; limit: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ matches: TextMatch[]; total: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst listed = new Set(await this.listCandidates(bounded));\n\t\t\t// The scope applies as the output arrives, so narrowing a search is what makes it\n\t\t\t// lighter: matches outside it are never stored, however many the program prints.\n\t\t\tconst kept: TextMatch[] = [];\n\t\t\tawait this.findMatches(query.pattern, query.ignoreCase, bounded, match => {\n\t\t\t\tif (!listed.has(match.path) || !this.inScope(match.path, query)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (kept.length === maxStoredMatches) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`More than ${String(maxStoredMatches)} lines match; narrow the pattern, path or glob`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tkept.push({ ...match, text: match.text.slice(0, maxStoredLineLength) });\n\t\t\t});\n\n\t\t\t// Only the files that matched are checked on disk, not the whole repository.\n\t\t\tconst regular = new Set(await this.regularFiles([...new Set(kept.map(match => match.path))]));\n\t\t\tconst matches = kept\n\t\t\t\t.filter(match => regular.has(match.path))\n\t\t\t\t.sort((a, b) => compareText(a.path, b.path) || a.line - b.line);\n\n\t\t\treturn {\n\t\t\t\tmatches: matches.slice(0, query.limit),\n\t\t\t\ttotal: matches.length,\n\t\t\t\ttruncated: matches.length > query.limit,\n\t\t\t};\n\t\t});\n\t}\n\n\treadFile(\n\t\tquery: { path: string; fromLine: number; lineCount: number },\n\t\tsignal: AbortSignal,\n\t): Promise<{ lines: string[]; totalLines: number; truncated: boolean }> {\n\t\treturn this.withTimeout(signal, async bounded => {\n\t\t\tconst path = this.toRelative(query.path);\n\t\t\tconst visible =\n\t\t\t\tpath !== undefined &&\n\t\t\t\t(await this.listCandidates(bounded)).includes(path) &&\n\t\t\t\t(await this.regularFiles([path])).length === 1;\n\t\t\t// One answer for a missing file and an ignored one, or the model would learn the secret exists.\n\t\t\tif (!visible) {\n\t\t\t\tthrow new Error(`No such file: ${query.path}`);\n\t\t\t}\n\n\t\t\tconst absolute = join(this.root, path);\n\t\t\tif (await isBinary(absolute)) {\n\t\t\t\tthrow new Error(`Not a text file: ${query.path}`);\n\t\t\t}\n\n\t\t\t// Streamed, so a large file costs its length in lines, not in memory.\n\t\t\tconst reader = createInterface({\n\t\t\t\tinput: createReadStream(absolute, { encoding: 'utf8', signal: bounded }),\n\t\t\t\tcrlfDelay: Infinity,\n\t\t\t});\n\t\t\tconst last = query.fromLine + query.lineCount - 1;\n\t\t\tconst lines: string[] = [];\n\t\t\tlet totalLines = 0;\n\t\t\tfor await (const line of reader) {\n\t\t\t\ttotalLines++;\n\t\t\t\tif (totalLines >= query.fromLine && totalLines <= last) {\n\t\t\t\t\tlines.push(line);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn { lines, totalLines, truncated: totalLines > last };\n\t\t});\n\t}\n\n\t/**\n\t * Runs an operation under the run's signal and this workspace's time limit together. The\n\t * run's cancellation escapes unchanged; the time limit becomes an error the model can act on.\n\t */\n\tprivate async withTimeout<Result>(\n\t\tsignal: AbortSignal,\n\t\toperation: (bounded: AbortSignal) => Promise<Result>,\n\t): Promise<Result> {\n\t\tsignal.throwIfAborted();\n\t\tconst timeout = AbortSignal.timeout(this.timeoutMs);\n\t\ttry {\n\t\t\treturn await operation(AbortSignal.any([signal, timeout]));\n\t\t} catch (error) {\n\t\t\tsignal.throwIfAborted();\n\t\t\tif (timeout.aborted) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`The operation took longer than ${String(this.timeoutMs / 1000)} s; narrow the pattern, path or glob`,\n\t\t\t\t\t{ cause: error },\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/** Regular files only: a symlink could point out of the root, and a deleted file is gone. */\n\tprivate async regularFiles(paths: string[]): Promise<string[]> {\n\t\tconst regular = await Promise.all(\n\t\t\tpaths.map(path =>\n\t\t\t\tlstat(join(this.root, path)).then(\n\t\t\t\t\tstats => stats.isFile(),\n\t\t\t\t\t() => false,\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\n\t\treturn paths.filter((_, index) => regular[index]);\n\t}\n\n\tprivate inScope(path: string, { path: scope, glob }: { path?: string; glob?: string }): boolean {\n\t\tif (scope !== undefined) {\n\t\t\tconst folder = this.toRelative(scope);\n\t\t\tif (folder === undefined) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (folder !== '' && path !== folder && !path.startsWith(`${folder}/`)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn glob === undefined || matchesGlob(path, glob);\n\t}\n\n\t/** A path relative to the root with `/`, '' for the root itself, or undefined when it leaves it. */\n\tprivate toRelative(path: string): string | undefined {\n\t\tconst relativePath = relative(this.root, resolve(this.root, path));\n\t\tif (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\treturn relativePath.split(sep).join('/');\n\t}\n}\n\nasync function isBinary(path: string): Promise<boolean> {\n\tconst file = await open(path);\n\ttry {\n\t\tconst probe = Buffer.alloc(binaryProbeBytes);\n\t\tconst { bytesRead } = await file.read(probe, 0, probe.length, 0);\n\t\treturn probe.subarray(0, bytesRead).includes(0);\n\t} finally {\n\t\tawait file.close();\n\t}\n}\n"]}
@@ -0,0 +1,55 @@
1
+ import type { FileState, RunJournal } from '#src/recovery/domain/recoveryStore';
2
+ /**
3
+ * Every file a command's checkpoint covers now, by its real path, absolute: what `.gitignore`
4
+ * does not exclude in the `write` roots, less what the access policy closes to writing by name.
5
+ */
6
+ export type CoveredFiles = (signal: AbortSignal) => Promise<string[]>;
7
+ /** A file a command changed, as the checkpoint found it. */
8
+ export interface ObservedChange {
9
+ path: string;
10
+ before: FileState;
11
+ after: FileState;
12
+ }
13
+ /**
14
+ * The files a command may change, captured right before it runs and compared right after, so
15
+ * what it changed is in the run's history like any tool's change, and can be undone.
16
+ *
17
+ * `begin` stores the content of every covered file and a manifest of them, then records the
18
+ * observation in the journal; if any of that fails, the command must not run. `end` lists the
19
+ * covered files again, and records each one that differs from the manifest as a change made by
20
+ * the command. This first version reads and hashes everything both times, trusting neither size
21
+ * nor modification time, and keeps what that cost with the observation.
22
+ */
23
+ export declare class CommandCheckpoint {
24
+ private readonly journal;
25
+ private readonly covered;
26
+ private readonly by;
27
+ private readonly manifest;
28
+ /** Covered files it could not copy before, which it leaves out after too. */
29
+ private readonly uncopyable;
30
+ private readonly sequence;
31
+ private readonly measure;
32
+ private constructor();
33
+ /** Captures every covered file. Cancelling it, or any failure, leaves the command unstarted. */
34
+ static begin({ journal, covered, by, signal, }: {
35
+ journal: RunJournal;
36
+ covered: CoveredFiles;
37
+ /** The command, as the history will name it. */
38
+ by: string;
39
+ signal: AbortSignal;
40
+ }): Promise<CommandCheckpoint>;
41
+ /**
42
+ * Compares the covered files with the capture and records every change found. It is never
43
+ * cancelled: once the command has run, what it changed must reach the history. Unless
44
+ * `complete`, the observation stays open, because the command may still be changing files.
45
+ *
46
+ * @returns the changes, and the paths it could not record because they are now something the
47
+ * history cannot copy, such as a file the command replaced with a folder, or made too large.
48
+ */
49
+ end({ complete }: {
50
+ complete: boolean;
51
+ }): Promise<{
52
+ changes: ObservedChange[];
53
+ unrecorded: string[];
54
+ }>;
55
+ }