@modelprofile.com/flexharness 10.17.1 → 10.19.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 (108) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/errors.d.ts +4 -0
  3. package/dist_ts/errors.js +9 -1
  4. package/dist_ts/functions.toolpermissions.d.ts +24 -0
  5. package/dist_ts/functions.toolpermissions.js +38 -0
  6. package/dist_ts/index.d.ts +1 -0
  7. package/dist_ts/index.js +2 -1
  8. package/dist_ts/plugins.d.ts +1 -1
  9. package/dist_ts/plugins.js +1 -1
  10. package/dist_ts_chat_harness/classes.chatclient.d.ts +95 -0
  11. package/dist_ts_chat_harness/classes.chatclient.js +263 -0
  12. package/dist_ts_chat_harness/classes.chatrequesterror.d.ts +4 -0
  13. package/dist_ts_chat_harness/classes.chatrequesterror.js +8 -0
  14. package/dist_ts_chat_harness/classes.transcript.d.ts +1 -1
  15. package/dist_ts_chat_harness/classes.transcript.js +1 -1
  16. package/dist_ts_chat_harness/functions.intents.d.ts +1 -2
  17. package/dist_ts_chat_harness/functions.intents.js +1 -1
  18. package/dist_ts_chat_harness/functions.mapping.d.ts +1 -1
  19. package/dist_ts_chat_harness/functions.mapping.js +1 -1
  20. package/dist_ts_chat_harness/index.d.ts +3 -0
  21. package/dist_ts_chat_harness/index.js +3 -1
  22. package/dist_ts_chat_harness/interfaces.chat.d.ts +136 -0
  23. package/dist_ts_chat_harness/interfaces.chat.js +2 -0
  24. package/dist_ts_chat_harness/plugins.js +1 -1
  25. package/dist_ts_chat_harness_server/classes.chathub.d.ts +78 -0
  26. package/dist_ts_chat_harness_server/classes.chathub.js +332 -0
  27. package/dist_ts_chat_harness_server/classes.chatsubscription.d.ts +55 -0
  28. package/dist_ts_chat_harness_server/classes.chatsubscription.js +236 -0
  29. package/dist_ts_chat_harness_server/classes.sessionlists.d.ts +21 -0
  30. package/dist_ts_chat_harness_server/classes.sessionlists.js +70 -0
  31. package/dist_ts_chat_harness_server/classes.turngate.d.ts +38 -0
  32. package/dist_ts_chat_harness_server/classes.turngate.js +207 -0
  33. package/dist_ts_chat_harness_server/errors.d.ts +5 -0
  34. package/dist_ts_chat_harness_server/errors.js +9 -0
  35. package/dist_ts_chat_harness_server/functions.errorprojector.d.ts +11 -0
  36. package/dist_ts_chat_harness_server/functions.errorprojector.js +32 -0
  37. package/dist_ts_chat_harness_server/functions.validation.d.ts +18 -0
  38. package/dist_ts_chat_harness_server/functions.validation.js +88 -0
  39. package/dist_ts_chat_harness_server/index.d.ts +4 -0
  40. package/dist_ts_chat_harness_server/index.js +4 -0
  41. package/dist_ts_chat_harness_server/interfaces.d.ts +67 -0
  42. package/dist_ts_chat_harness_server/interfaces.js +2 -0
  43. package/dist_ts_chat_harness_server/plugins.d.ts +7 -0
  44. package/dist_ts_chat_harness_server/plugins.js +6 -0
  45. package/dist_ts_chat_harness_typedsocket/classes.clienttransport.d.ts +22 -0
  46. package/dist_ts_chat_harness_typedsocket/classes.clienttransport.js +55 -0
  47. package/dist_ts_chat_harness_typedsocket/classes.serverbinding.d.ts +46 -0
  48. package/dist_ts_chat_harness_typedsocket/classes.serverbinding.js +110 -0
  49. package/dist_ts_chat_harness_typedsocket/index.d.ts +3 -0
  50. package/dist_ts_chat_harness_typedsocket/index.js +3 -0
  51. package/dist_ts_chat_harness_typedsocket/interfaces.requests.d.ts +38 -0
  52. package/dist_ts_chat_harness_typedsocket/interfaces.requests.js +2 -0
  53. package/dist_ts_chat_harness_typedsocket/plugins.d.ts +9 -0
  54. package/dist_ts_chat_harness_typedsocket/plugins.js +8 -0
  55. package/dist_ts_ocr/ocr.mistral.d.ts +20 -1
  56. package/dist_ts_ocr/ocr.mistral.js +66 -43
  57. package/dist_ts_tools/tool.filesystem.d.ts +2 -0
  58. package/dist_ts_tools/tool.filesystem.js +42 -1
  59. package/dist_ts_tools_node/classes.workspacereversion.d.ts +48 -0
  60. package/dist_ts_tools_node/classes.workspacereversion.js +202 -0
  61. package/dist_ts_tools_node/functions.fileimages.d.ts +22 -0
  62. package/dist_ts_tools_node/functions.fileimages.js +87 -0
  63. package/dist_ts_tools_node/index.d.ts +3 -1
  64. package/dist_ts_tools_node/index.js +2 -1
  65. package/dist_ts_tools_node/plugins.d.ts +1 -0
  66. package/dist_ts_tools_node/tool.context.d.ts +20 -0
  67. package/dist_ts_tools_node/tool.context.js +25 -4
  68. package/package.json +28 -4
  69. package/ts/00_commitinfo_data.ts +1 -1
  70. package/ts/errors.ts +10 -0
  71. package/ts/functions.toolpermissions.ts +60 -0
  72. package/ts/index.ts +6 -0
  73. package/ts/plugins.ts +1 -0
  74. package/ts_chat_harness/classes.chatclient.ts +343 -0
  75. package/ts_chat_harness/classes.chatrequesterror.ts +7 -0
  76. package/ts_chat_harness/classes.transcript.ts +1 -1
  77. package/ts_chat_harness/functions.intents.ts +2 -2
  78. package/ts_chat_harness/functions.mapping.ts +1 -1
  79. package/ts_chat_harness/index.ts +20 -0
  80. package/ts_chat_harness/interfaces.chat.ts +108 -0
  81. package/ts_chat_harness/readme.md +24 -0
  82. package/ts_chat_harness_server/classes.chathub.ts +400 -0
  83. package/ts_chat_harness_server/classes.chatsubscription.ts +257 -0
  84. package/ts_chat_harness_server/classes.sessionlists.ts +90 -0
  85. package/ts_chat_harness_server/classes.turngate.ts +247 -0
  86. package/ts_chat_harness_server/errors.ts +9 -0
  87. package/ts_chat_harness_server/functions.errorprojector.ts +40 -0
  88. package/ts_chat_harness_server/functions.validation.ts +104 -0
  89. package/ts_chat_harness_server/index.ts +19 -0
  90. package/ts_chat_harness_server/interfaces.ts +92 -0
  91. package/ts_chat_harness_server/plugins.ts +62 -0
  92. package/ts_chat_harness_server/readme.md +91 -0
  93. package/ts_chat_harness_typedsocket/classes.clienttransport.ts +87 -0
  94. package/ts_chat_harness_typedsocket/classes.serverbinding.ts +172 -0
  95. package/ts_chat_harness_typedsocket/index.ts +17 -0
  96. package/ts_chat_harness_typedsocket/interfaces.requests.ts +40 -0
  97. package/ts_chat_harness_typedsocket/plugins.ts +28 -0
  98. package/ts_chat_harness_typedsocket/readme.md +57 -0
  99. package/ts_ocr/ocr.mistral.ts +102 -49
  100. package/ts_ocr/readme.md +12 -1
  101. package/ts_tools/readme.md +10 -0
  102. package/ts_tools/tool.filesystem.ts +55 -0
  103. package/ts_tools_node/classes.workspacereversion.ts +255 -0
  104. package/ts_tools_node/functions.fileimages.ts +89 -0
  105. package/ts_tools_node/index.ts +7 -1
  106. package/ts_tools_node/plugins.ts +11 -0
  107. package/ts_tools_node/readme.md +27 -0
  108. package/ts_tools_node/tool.context.ts +49 -3
@@ -14,6 +14,18 @@ export interface ISmartAiOcrImageInput {
14
14
  mimeType: TSmartAiOcrImageMimeType;
15
15
  }
16
16
 
17
+ /** Mistral OCR reads PDF, PPTX, DOCX and other document formats. */
18
+ export type TSmartAiOcrDocumentMimeType =
19
+ | 'application/pdf'
20
+ | 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
21
+ | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
22
+ | string;
23
+
24
+ export interface ISmartAiOcrDocumentInput {
25
+ dataBase64: string;
26
+ mimeType: TSmartAiOcrDocumentMimeType;
27
+ }
28
+
17
29
  export interface ISmartAiOcrPageResult {
18
30
  index: number;
19
31
  text: string;
@@ -34,6 +46,14 @@ export interface ISmartAiOcrEngine {
34
46
  ) => Promise<ISmartAiOcrResult<IMistralOcrResponse>>;
35
47
  }
36
48
 
49
+ /** The Mistral OCR engine also reads whole documents, such as a PDF with all its pages. */
50
+ export interface ISmartAiMistralOcrEngine extends ISmartAiOcrEngine {
51
+ recognizeDocument: (
52
+ input: ISmartAiOcrDocumentInput,
53
+ options?: ISmartAiMistralOcrRecognizeOptions
54
+ ) => Promise<ISmartAiOcrResult<IMistralOcrResponse>>;
55
+ }
56
+
37
57
  export interface IMistralOcrPageConfidenceScores {
38
58
  average_page_confidence_score?: number;
39
59
  averagePageConfidenceScore?: number;
@@ -70,8 +90,18 @@ export interface IMistralOcrRequest {
70
90
  confidence_scores_granularity?: TSmartAiMistralOcrConfidenceScoresGranularity;
71
91
  }
72
92
 
93
+ /** A document (PDF, PPTX, DOCX and other formats) as a URL or a base64 data URL; the other fields are those of an image request. */
94
+ export interface IMistralOcrDocumentRequest extends Omit<IMistralOcrRequest, 'document'> {
95
+ document: {
96
+ type: 'document_url';
97
+ document_url: string;
98
+ };
99
+ }
100
+
73
101
  export interface ISmartAiMistralOcrTransport {
74
102
  process: (request: IMistralOcrRequest) => Promise<IMistralOcrResponse>;
103
+ /** Reads documents. A transport without it reads images only, and `recognizeDocument` refuses documents. */
104
+ processDocument?: (request: IMistralOcrDocumentRequest) => Promise<IMistralOcrResponse>;
75
105
  }
76
106
 
77
107
  export interface ISmartAiMistralOcrOptions {
@@ -101,29 +131,28 @@ const createMistralOcrHttpTransport = (options: {
101
131
  apiKey?: string;
102
132
  endpointUrl?: string;
103
133
  }): ISmartAiMistralOcrTransport => {
104
- return {
105
- process: async (request) => {
106
- if (!options.apiKey) {
107
- throw new Error('Mistral OCR requires an apiKey when no custom transport is provided.');
108
- }
109
-
110
- const response = await fetch(options.endpointUrl ?? defaultMistralOcrEndpointUrl, {
111
- method: 'POST',
112
- headers: {
113
- Authorization: `Bearer ${options.apiKey}`,
114
- 'Content-Type': 'application/json',
115
- },
116
- body: JSON.stringify(request),
117
- });
134
+ const post = async (request: IMistralOcrRequest | IMistralOcrDocumentRequest): Promise<IMistralOcrResponse> => {
135
+ if (!options.apiKey) {
136
+ throw new Error('Mistral OCR requires an apiKey when no custom transport is provided.');
137
+ }
138
+
139
+ const response = await fetch(options.endpointUrl ?? defaultMistralOcrEndpointUrl, {
140
+ method: 'POST',
141
+ headers: {
142
+ Authorization: `Bearer ${options.apiKey}`,
143
+ 'Content-Type': 'application/json',
144
+ },
145
+ body: JSON.stringify(request),
146
+ });
118
147
 
119
- if (!response.ok) {
120
- const errorBody = await response.text();
121
- throw new Error(`Mistral OCR request failed with status ${response.status}: ${errorBody}`);
122
- }
148
+ if (!response.ok) {
149
+ const errorBody = await response.text();
150
+ throw new Error(`Mistral OCR request failed with status ${response.status}: ${errorBody}`);
151
+ }
123
152
 
124
- return (await response.json()) as IMistralOcrResponse;
125
- },
153
+ return (await response.json()) as IMistralOcrResponse;
126
154
  };
155
+ return { process: post, processDocument: post };
127
156
  };
128
157
 
129
158
  const getPageConfidence = (page: IMistralOcrPageResponse): number | undefined => {
@@ -134,9 +163,30 @@ const getPageConfidence = (page: IMistralOcrPageResponse): number | undefined =>
134
163
  );
135
164
  };
136
165
 
166
+ const toOcrResult = (response: IMistralOcrResponse): ISmartAiOcrResult<IMistralOcrResponse> => {
167
+ const pages = response.pages.map((page) => ({
168
+ index: page.index,
169
+ text: page.markdown,
170
+ confidence: getPageConfidence(page),
171
+ }));
172
+ const pageConfidences = pages
173
+ .map((page) => page.confidence)
174
+ .filter((confidence): confidence is number => typeof confidence === 'number');
175
+ const confidence = pageConfidences.length
176
+ ? pageConfidences.reduce((sum, value) => sum + value, 0) / pageConfidences.length
177
+ : undefined;
178
+
179
+ return {
180
+ text: pages.map((page) => page.text).join('\n\n').trim(),
181
+ confidence,
182
+ pages,
183
+ raw: response,
184
+ };
185
+ };
186
+
137
187
  export const createMistralOcrEngine = (
138
188
  options: ISmartAiMistralOcrOptions = {}
139
- ): ISmartAiOcrEngine => {
189
+ ): ISmartAiMistralOcrEngine => {
140
190
  const transport =
141
191
  options.transport ??
142
192
  createMistralOcrHttpTransport({
@@ -145,6 +195,16 @@ export const createMistralOcrEngine = (
145
195
  });
146
196
  const model = options.model ?? defaultMistralOcrModel;
147
197
 
198
+ const settingsOf = (recognizeOptions: ISmartAiMistralOcrRecognizeOptions) => ({
199
+ include_image_base64:
200
+ recognizeOptions.includeImageBase64 ?? options.includeImageBase64 ?? false,
201
+ table_format: recognizeOptions.tableFormat ?? options.tableFormat,
202
+ extract_header: recognizeOptions.extractHeader ?? options.extractHeader,
203
+ extract_footer: recognizeOptions.extractFooter ?? options.extractFooter,
204
+ confidence_scores_granularity:
205
+ recognizeOptions.confidenceScoresGranularity ?? options.confidenceScoresGranularity,
206
+ });
207
+
148
208
  return {
149
209
  recognizeImage: async (input, recognizeOptions = {}) => {
150
210
  if (!input.dataBase64) {
@@ -153,40 +213,33 @@ export const createMistralOcrEngine = (
153
213
  if (!input.mimeType) {
154
214
  throw new Error('Mistral OCR image input requires mimeType.');
155
215
  }
156
-
157
- const response = await transport.process({
216
+ return toOcrResult(await transport.process({
158
217
  model,
159
218
  document: {
160
219
  type: 'image_url',
161
220
  image_url: `data:${input.mimeType};base64,${input.dataBase64}`,
162
221
  },
163
- include_image_base64:
164
- recognizeOptions.includeImageBase64 ?? options.includeImageBase64 ?? false,
165
- table_format: recognizeOptions.tableFormat ?? options.tableFormat,
166
- extract_header: recognizeOptions.extractHeader ?? options.extractHeader,
167
- extract_footer: recognizeOptions.extractFooter ?? options.extractFooter,
168
- confidence_scores_granularity:
169
- recognizeOptions.confidenceScoresGranularity ?? options.confidenceScoresGranularity,
170
- });
171
-
172
- const pages = response.pages.map((page) => ({
173
- index: page.index,
174
- text: page.markdown,
175
- confidence: getPageConfidence(page),
222
+ ...settingsOf(recognizeOptions),
223
+ }));
224
+ },
225
+ recognizeDocument: async (input, recognizeOptions = {}) => {
226
+ if (!transport.processDocument) {
227
+ throw new Error('This Mistral OCR transport reads images only: it has no processDocument, so it cannot read documents.');
228
+ }
229
+ if (!input.dataBase64) {
230
+ throw new Error('Mistral OCR document input requires dataBase64.');
231
+ }
232
+ if (!input.mimeType) {
233
+ throw new Error('Mistral OCR document input requires mimeType.');
234
+ }
235
+ return toOcrResult(await transport.processDocument({
236
+ model,
237
+ document: {
238
+ type: 'document_url',
239
+ document_url: `data:${input.mimeType};base64,${input.dataBase64}`,
240
+ },
241
+ ...settingsOf(recognizeOptions),
176
242
  }));
177
- const pageConfidences = pages
178
- .map((page) => page.confidence)
179
- .filter((confidence): confidence is number => typeof confidence === 'number');
180
- const confidence = pageConfidences.length
181
- ? pageConfidences.reduce((sum, value) => sum + value, 0) / pageConfidences.length
182
- : undefined;
183
-
184
- return {
185
- text: pages.map((page) => page.text).join('\n\n').trim(),
186
- confidence,
187
- pages,
188
- raw: response,
189
- };
190
243
  },
191
244
  };
192
245
  };
package/ts_ocr/readme.md CHANGED
@@ -1,4 +1,4 @@
1
- Image OCR with Mistral or an injected recognition engine. This subpath imports no runtime dependencies and works with Node buffers and browser byte arrays.
1
+ Image OCR with Mistral or an injected recognition engine, and document OCR with Mistral. This subpath imports no runtime dependencies and works with Node buffers and browser byte arrays.
2
2
 
3
3
  Part of the [FlexHarness toolbox](https://code.foss.global/modelprofile.com/flexharness).
4
4
  Install the components your project uses. Provider adapters are registered explicitly;
@@ -20,6 +20,17 @@ console.log(result.text);
20
20
 
21
21
  Constructor and call options accept `engine` or `mistralOptions`. A per-call engine takes precedence over the constructor engine. Otherwise Mistral settings merge, with per-call settings taking precedence. `recognizeOptions` controls each request. `recognizeImage` accepts an existing `{ dataBase64, mimeType }` image; `createMistralOcrEngine` remains available for lower-level integrations.
22
22
 
23
+ `createMistralOcrEngine()` also reads whole documents: `recognizeDocument({ dataBase64, mimeType: 'application/pdf' })` sends the document as a `document_url` data URL, as Mistral's OCR API takes PDF, PPTX, DOCX and other document formats, and returns every page. Mistral accepts documents of up to 50 MB and 1,000 pages.
24
+
25
+ A custom `transport` receives image requests (`IMistralOcrRequest`) through `process`, as before. It reads documents only when it also implements the optional `processDocument`, which receives an `IMistralOcrDocumentRequest`, whose `document` is a `document_url`; for a transport without it, `recognizeDocument` fails with "This Mistral OCR transport reads images only". The built-in transport implements both.
26
+
27
+ ```typescript
28
+ import { createMistralOcrEngine } from '@modelprofile.com/flexharness/ocr';
29
+
30
+ const engine = createMistralOcrEngine({ apiKey: process.env.MISTRAL_API_KEY });
31
+ const { text, pages } = await engine.recognizeDocument({ dataBase64: pdfBase64, mimeType: 'application/pdf' });
32
+ ```
33
+
23
34
  Model-based PDF text extraction lives in `@modelprofile.com/flexharness/documents`. Native `ocrmypdf` processing remains in `@push.rocks/smartocr`.
24
35
 
25
36
  ## Issue Reporting and Security
@@ -11,6 +11,16 @@ pnpm add @modelprofile.com/flexharness
11
11
  See the [toolbox usage and migration guide](https://code.foss.global/modelprofile.com/flexharness#readme)
12
12
  for composition examples and the SmartAI/SmartAgent migration map.
13
13
 
14
+ ## Filesystem tools
15
+
16
+ `createFilesystemTools(context, options)` exposes `read_file`, `write_file`, `list_directory` and, unless `includeDelete: false`, `delete_file` over the context's `fs`. `write_file` and `delete_file` ask `context.requestPermission` (`write`, `delete`) before they change anything.
17
+
18
+ `includeEdit: true` adds `edit_file { path, find, replace, replaceAll? }`. It replaces one exact occurrence of `find`, or every occurrence with `replaceAll`, and refuses a `find` that does not occur or, without `replaceAll`, occurs more than once. The replacement text is literal. It asks for `write` permission before it reads the file, so an edit made while the request waits is kept. It is off by default, so the tools an existing agent sees do not change.
19
+
20
+ ```typescript
21
+ const tools = createFilesystemTools(context, { includeEdit: true });
22
+ ```
23
+
14
24
  ## Issue Reporting and Security
15
25
 
16
26
  Report issues and security concerns through [community.foss.global](https://community.foss.global).
@@ -7,6 +7,8 @@ import {
7
7
  export interface ICreateFilesystemToolsOptions {
8
8
  /** Include delete_file. Default: true. */
9
9
  includeDelete?: boolean;
10
+ /** Include edit_file, which replaces an exact snippet of a file. Default: false. */
11
+ includeEdit?: boolean;
10
12
  /** Maximum output lines before truncating. */
11
13
  maxLines?: number;
12
14
  /** Maximum output bytes before truncating. */
@@ -110,6 +112,59 @@ export function createFilesystemTools(context: IToolExecutionContext, options: I
110
112
  }),
111
113
  };
112
114
 
115
+ if (options.includeEdit === true) {
116
+ tools.edit_file = plugins.tool({
117
+ description:
118
+ 'Replace an exact snippet of a UTF-8 file in the active workspace. `find` must occur exactly once unless `replaceAll` is set; include enough surrounding text to make it unique. Requires host permission when configured.',
119
+ inputSchema: plugins.z.object({
120
+ path: plugins.z.string().describe('File path'),
121
+ find: plugins.z.string().min(1).describe('Exact text to replace, copied from the current file'),
122
+ replace: plugins.z.string().describe('The text to put in its place'),
123
+ replaceAll: plugins.z
124
+ .boolean()
125
+ .optional()
126
+ .describe('Replace every occurrence instead of exactly one. Default: false'),
127
+ }),
128
+ execute: async (
129
+ {
130
+ path: filePath,
131
+ find,
132
+ replace,
133
+ replaceAll,
134
+ }: { path: string; find: string; replace: string; replaceAll?: boolean },
135
+ invocationOptions?: plugins.ToolExecutionOptions<unknown>,
136
+ ) => {
137
+ if (!context.fs) {
138
+ throw new Error('Filesystem tools are not available in this execution context.');
139
+ }
140
+ const abortSignal = invocationOptions?.abortSignal ?? context.abortSignal;
141
+ const invocation = { toolCallId: invocationOptions?.toolCallId, abortSignal };
142
+ // asked before the file is read: an edit made while the request waits is kept, not overwritten
143
+ await context.requestPermission?.({
144
+ type: 'write',
145
+ title: 'Edit file',
146
+ metadata: { path: filePath },
147
+ toolCallId: invocationOptions?.toolCallId,
148
+ abortSignal,
149
+ });
150
+ const content = await context.fs.readFile(filePath, undefined, invocation);
151
+ const occurrences = content.split(find).length - 1;
152
+ if (occurrences === 0) {
153
+ throw new Error(`The text to replace does not occur in ${filePath}.`);
154
+ }
155
+ if (occurrences > 1 && replaceAll !== true) {
156
+ throw new Error(
157
+ `The text to replace occurs ${occurrences} times in ${filePath}; include more surrounding text or set replaceAll.`,
158
+ );
159
+ }
160
+ const updated = replaceAll === true ? content.split(find).join(replace) : content.replace(find, () => replace);
161
+ await context.fs.writeFile(filePath, updated, invocation);
162
+ const replaced = replaceAll === true ? occurrences : 1;
163
+ return truncate(`Replaced ${replaced} occurrence${replaced === 1 ? '' : 's'} in ${filePath}`);
164
+ },
165
+ });
166
+ }
167
+
113
168
  if (options.includeDelete !== false) {
114
169
  tools.delete_file = plugins.tool({
115
170
  description: 'Delete a file or empty directory in the active workspace. Requires host permission when configured.',
@@ -0,0 +1,255 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IToolFileChange, IToolFileChangeObserver } from './tool.context.js';
3
+ import {
4
+ fileImageBytes,
5
+ fileImagesEqual,
6
+ missingAncestors,
7
+ readFileImage,
8
+ removeEmptyDirectories,
9
+ writeFileImage,
10
+ type TFileImage,
11
+ } from './functions.fileimages.js';
12
+
13
+ export interface IInMemoryWorkspaceReversionOptions {
14
+ /** The workspace a revertible turn names in the session's reversion info. */
15
+ workspace: plugins.IFlexAffectedWorkspace;
16
+ /**
17
+ * The bytes of file images one turn may hold. A turn that changes more is recorded as
18
+ * nonrevertible (`capture-too-large`) and its images are dropped. Default: 64 MiB.
19
+ */
20
+ maxCaptureBytes?: number;
21
+ }
22
+
23
+ /** One file a turn changed: what it held before the turn's first change and after the turn. */
24
+ interface IFileRecord {
25
+ before: TFileImage;
26
+ after?: TFileImage;
27
+ /** directories the turn's first write created, deepest first */
28
+ createdDirectories: string[];
29
+ }
30
+
31
+ interface ICapture {
32
+ captureId: string;
33
+ runId: string;
34
+ state: 'prepared' | 'finalized';
35
+ files: Map<string, IFileRecord>;
36
+ bytes: number;
37
+ nonrevertibleReason?: string;
38
+ outcome?: plugins.TFlexTurnReversionFinalizedOutcome;
39
+ appliedOperations: Set<string>;
40
+ /** apply operations that failed after a partial change could not be rolled back */
41
+ uncertainOperations: Set<string>;
42
+ }
43
+
44
+ interface ICaptureReference {
45
+ captureId: string;
46
+ }
47
+
48
+ const DEFAULT_MAX_CAPTURE_BYTES = 64 * 1024 * 1024;
49
+
50
+ const isCaptureReference = (valueArg: plugins.TJsonValue): valueArg is plugins.TJsonValue & ICaptureReference =>
51
+ !!valueArg
52
+ && typeof valueArg === 'object'
53
+ && !Array.isArray(valueArg)
54
+ && typeof valueArg.captureId === 'string';
55
+
56
+ /**
57
+ * Turn reversion for the local filesystem tools, held in memory: FlexHarness `/undo` and `/redo`
58
+ * move a turn's file changes together with its transcript.
59
+ *
60
+ * Give each run's `createLocalToolExecutionContext` the observer `observerFor(context.runId)`. The
61
+ * first change of a file in a turn records what the file held before; `finalize` records what it
62
+ * holds after. Undo puts the before images back and redo the after images, each only while every
63
+ * file still holds what the other direction left, so a later edit of the user is never overwritten.
64
+ * A turn that ran a shell command is nonrevertible, since the command may have changed files no
65
+ * observer saw.
66
+ *
67
+ * Captures live as long as the process. A capture FlexHarness asks about after a restart is
68
+ * `missing`, and undoing past it fails without changing anything. Only root-session runs are
69
+ * captured; a change of a run without a prepared capture is refused.
70
+ */
71
+ export class InMemoryWorkspaceReversion<TScope = unknown> implements plugins.IFlexTurnReversionProviderV2<TScope> {
72
+ public readonly protocolVersion = 2;
73
+
74
+ private readonly workspace: plugins.IFlexAffectedWorkspace;
75
+ private readonly maxCaptureBytes: number;
76
+ private readonly captures = new Map<string, ICapture>();
77
+ private readonly captureIdsByRun = new Map<string, string>();
78
+
79
+ constructor(optionsArg: IInMemoryWorkspaceReversionOptions) {
80
+ this.workspace = { id: optionsArg.workspace.id, label: optionsArg.workspace.label };
81
+ this.maxCaptureBytes = optionsArg.maxCaptureBytes ?? DEFAULT_MAX_CAPTURE_BYTES;
82
+ if (!Number.isSafeInteger(this.maxCaptureBytes) || this.maxCaptureBytes <= 0) {
83
+ throw new Error('maxCaptureBytes must be a positive integer.');
84
+ }
85
+ }
86
+
87
+ /** The observer of one run's file changes and shell commands. */
88
+ public observerFor(runIdArg: string): IToolFileChangeObserver {
89
+ return {
90
+ beforeFileChange: (changeArg) => this.recordChange(runIdArg, changeArg),
91
+ beforeShellCommand: () => {
92
+ this.markNonrevertible(this.requirePreparedCapture(runIdArg), 'shell-command');
93
+ },
94
+ };
95
+ }
96
+
97
+ public prepare(contextArg: plugins.IFlexTurnReversionBaseContext<TScope>): void {
98
+ contextArg.signal.throwIfAborted();
99
+ if (this.captures.has(contextArg.captureId)) return;
100
+ this.captures.set(contextArg.captureId, {
101
+ captureId: contextArg.captureId,
102
+ runId: contextArg.runId,
103
+ state: 'prepared',
104
+ files: new Map(),
105
+ bytes: 0,
106
+ appliedOperations: new Set(),
107
+ uncertainOperations: new Set(),
108
+ });
109
+ this.captureIdsByRun.set(contextArg.runId, contextArg.captureId);
110
+ }
111
+
112
+ public inspectCapture(
113
+ contextArg: plugins.IFlexTurnReversionBaseContext<TScope>,
114
+ ): plugins.TFlexTurnReversionCaptureInspectionV2 {
115
+ const capture = this.captures.get(contextArg.captureId);
116
+ if (!capture) return { status: 'missing' };
117
+ return capture.outcome ? { status: 'finalized', outcome: capture.outcome } : { status: 'prepared' };
118
+ }
119
+
120
+ public async finalize(
121
+ contextArg: plugins.IFlexTurnReversionBaseContext<TScope>,
122
+ ): Promise<plugins.TFlexTurnReversionFinalizedOutcome> {
123
+ contextArg.signal.throwIfAborted();
124
+ const capture = this.requireCapture(contextArg.captureId);
125
+ if (capture.outcome) return capture.outcome;
126
+ const reference: ICaptureReference = { captureId: capture.captureId };
127
+ if (!capture.nonrevertibleReason) {
128
+ for (const [filePath, record] of capture.files) {
129
+ record.after = await readFileImage(filePath);
130
+ if (fileImagesEqual(record.before, record.after)) capture.files.delete(filePath);
131
+ }
132
+ }
133
+ capture.state = 'finalized';
134
+ if (capture.nonrevertibleReason) {
135
+ capture.outcome = {
136
+ disposition: 'nonrevertible',
137
+ reference: { ...reference },
138
+ reasonCode: capture.nonrevertibleReason,
139
+ affectedWorkspaces: [this.workspace],
140
+ };
141
+ } else if (capture.files.size === 0) {
142
+ capture.outcome = { disposition: 'no-change', reference: { ...reference } };
143
+ } else {
144
+ capture.outcome = { disposition: 'revertible', reference: { ...reference }, affectedWorkspaces: [this.workspace] };
145
+ }
146
+ this.captureIdsByRun.delete(capture.runId);
147
+ return capture.outcome;
148
+ }
149
+
150
+ public async apply(contextArg: plugins.IFlexTurnReversionApplyContext<TScope>): Promise<void> {
151
+ contextArg.signal.throwIfAborted();
152
+ const capture = this.captureOf(contextArg.reference);
153
+ if (capture.outcome?.disposition !== 'revertible') {
154
+ throw new Error('This turn has no revertible workspace changes.');
155
+ }
156
+ const records = [...capture.files.entries()];
157
+ const expected = (recordArg: IFileRecord) => (contextArg.direction === 'undo' ? recordArg.after! : recordArg.before);
158
+ const target = (recordArg: IFileRecord) => (contextArg.direction === 'undo' ? recordArg.before : recordArg.after!);
159
+ for (const [filePath, record] of records) {
160
+ if (!fileImagesEqual(await readFileImage(filePath), expected(record))) {
161
+ throw new Error(`"${filePath}" changed after the turn; its changes were not ${contextArg.direction === 'undo' ? 'undone' : 'redone'}.`);
162
+ }
163
+ }
164
+ const written: Array<[string, IFileRecord]> = [];
165
+ try {
166
+ for (const entry of records) {
167
+ await writeFileImage(entry[0], target(entry[1]));
168
+ written.push(entry);
169
+ }
170
+ } catch (error) {
171
+ await this.rollBack(written, expected, contextArg.operationId, capture);
172
+ throw error;
173
+ }
174
+ if (contextArg.direction === 'undo') {
175
+ for (const [, record] of records) await removeEmptyDirectories(record.createdDirectories);
176
+ }
177
+ capture.appliedOperations.add(contextArg.operationId);
178
+ }
179
+
180
+ public inspectApply(
181
+ contextArg: plugins.IFlexTurnReversionApplyContext<TScope>,
182
+ ): plugins.IFlexTurnReversionApplyInspection {
183
+ const capture = this.captures.get(isCaptureReference(contextArg.reference) ? contextArg.reference.captureId : '');
184
+ if (!capture) return { status: 'not-applied' };
185
+ if (capture.appliedOperations.has(contextArg.operationId)) return { status: 'applied' };
186
+ if (capture.uncertainOperations.has(contextArg.operationId)) return { status: 'unknown' };
187
+ return { status: 'not-applied' };
188
+ }
189
+
190
+ public release(contextArg: plugins.IFlexTurnReversionReleaseContext<TScope>): void {
191
+ const captureId = isCaptureReference(contextArg.reference) ? contextArg.reference.captureId : contextArg.captureId;
192
+ const capture = this.captures.get(captureId);
193
+ if (!capture) return;
194
+ this.captures.delete(captureId);
195
+ if (this.captureIdsByRun.get(capture.runId) === captureId) this.captureIdsByRun.delete(capture.runId);
196
+ }
197
+
198
+ private async recordChange(runIdArg: string, changeArg: IToolFileChange): Promise<void> {
199
+ const capture = this.requirePreparedCapture(runIdArg);
200
+ if (capture.nonrevertibleReason || capture.files.has(changeArg.path)) return;
201
+ const before = await readFileImage(changeArg.path);
202
+ const createdDirectories = before.kind === 'absent' && changeArg.kind === 'write'
203
+ ? await missingAncestors(changeArg.path)
204
+ : [];
205
+ if (capture.files.has(changeArg.path)) return;
206
+ capture.bytes += fileImageBytes(before);
207
+ if (capture.bytes > this.maxCaptureBytes) {
208
+ this.markNonrevertible(capture, 'capture-too-large');
209
+ return;
210
+ }
211
+ capture.files.set(changeArg.path, { before, createdDirectories });
212
+ }
213
+
214
+ private markNonrevertible(captureArg: ICapture, reasonArg: string): void {
215
+ captureArg.nonrevertibleReason ??= reasonArg;
216
+ captureArg.files.clear();
217
+ captureArg.bytes = 0;
218
+ }
219
+
220
+ private async rollBack(
221
+ writtenArg: Array<[string, IFileRecord]>,
222
+ expectedArg: (recordArg: IFileRecord) => TFileImage,
223
+ operationIdArg: string,
224
+ captureArg: ICapture,
225
+ ): Promise<void> {
226
+ try {
227
+ for (const [filePath, record] of writtenArg.reverse()) await writeFileImage(filePath, expectedArg(record));
228
+ } catch {
229
+ // the workspace holds part of the change; FlexHarness learns that the outcome is unknown
230
+ captureArg.uncertainOperations.add(operationIdArg);
231
+ }
232
+ }
233
+
234
+ private requirePreparedCapture(runIdArg: string): ICapture {
235
+ const captureId = this.captureIdsByRun.get(runIdArg);
236
+ const capture = captureId ? this.captures.get(captureId) : undefined;
237
+ if (!capture || capture.state !== 'prepared') {
238
+ throw new Error(`No workspace capture is open for run "${runIdArg}"; the change was refused.`);
239
+ }
240
+ return capture;
241
+ }
242
+
243
+ private requireCapture(captureIdArg: string): ICapture {
244
+ const capture = this.captures.get(captureIdArg);
245
+ if (!capture) throw new Error(`The workspace capture "${captureIdArg}" is not held.`);
246
+ return capture;
247
+ }
248
+
249
+ private captureOf(referenceArg: plugins.TJsonValue): ICapture {
250
+ if (!isCaptureReference(referenceArg)) throw new Error('The workspace capture reference is malformed.');
251
+ const capture = this.captures.get(referenceArg.captureId);
252
+ if (!capture) throw new Error('The workspace changes of this turn are no longer held in memory.');
253
+ return capture;
254
+ }
255
+ }
@@ -0,0 +1,89 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /** What one path holds at a moment: nothing, a regular file, or a symbolic link. */
4
+ export type TFileImage =
5
+ | { kind: 'absent' }
6
+ | { kind: 'file'; bytes: Buffer; mode: number }
7
+ | { kind: 'symlink'; target: string };
8
+
9
+ const isMissing = (error: unknown): boolean => {
10
+ const code = (error as NodeJS.ErrnoException).code;
11
+ return code === 'ENOENT' || code === 'ENOTDIR';
12
+ };
13
+
14
+ /** Reads what the path holds, without following a final symbolic link. */
15
+ export const readFileImage = async (pathArg: string): Promise<TFileImage> => {
16
+ let stats: plugins.fs.Stats;
17
+ try {
18
+ stats = await plugins.fs.promises.lstat(pathArg);
19
+ } catch (error) {
20
+ if (isMissing(error)) return { kind: 'absent' };
21
+ throw error;
22
+ }
23
+ if (stats.isSymbolicLink()) return { kind: 'symlink', target: await plugins.fs.promises.readlink(pathArg) };
24
+ if (stats.isFile()) return { kind: 'file', bytes: await plugins.fs.promises.readFile(pathArg), mode: stats.mode & 0o7777 };
25
+ throw new Error(`"${pathArg}" is neither a file nor a symbolic link.`);
26
+ };
27
+
28
+ /** The bytes an image keeps in memory. */
29
+ export const fileImageBytes = (imageArg: TFileImage): number =>
30
+ imageArg.kind === 'file'
31
+ ? imageArg.bytes.byteLength
32
+ : imageArg.kind === 'symlink'
33
+ ? Buffer.byteLength(imageArg.target)
34
+ : 0;
35
+
36
+ export const fileImagesEqual = (leftArg: TFileImage, rightArg: TFileImage): boolean => {
37
+ if (leftArg.kind === 'absent' || rightArg.kind === 'absent') return leftArg.kind === rightArg.kind;
38
+ if (leftArg.kind === 'symlink' || rightArg.kind === 'symlink') {
39
+ return leftArg.kind === 'symlink' && rightArg.kind === 'symlink' && leftArg.target === rightArg.target;
40
+ }
41
+ return leftArg.bytes.equals(rightArg.bytes);
42
+ };
43
+
44
+ /** Puts the image at the path, creating parent directories as needed. */
45
+ export const writeFileImage = async (pathArg: string, imageArg: TFileImage): Promise<void> => {
46
+ if (imageArg.kind === 'absent') {
47
+ await plugins.fs.promises.rm(pathArg, { force: true });
48
+ return;
49
+ }
50
+ await plugins.fs.promises.mkdir(plugins.path.dirname(pathArg), { recursive: true });
51
+ if (imageArg.kind === 'symlink') {
52
+ await plugins.fs.promises.rm(pathArg, { force: true });
53
+ await plugins.fs.promises.symlink(imageArg.target, pathArg);
54
+ return;
55
+ }
56
+ if ((await readFileImage(pathArg)).kind === 'symlink') await plugins.fs.promises.rm(pathArg);
57
+ await plugins.fs.promises.writeFile(pathArg, imageArg.bytes);
58
+ await plugins.fs.promises.chmod(pathArg, imageArg.mode);
59
+ };
60
+
61
+ /** The ancestors of the path that do not exist yet, deepest first: a write would create them. */
62
+ export const missingAncestors = async (pathArg: string): Promise<string[]> => {
63
+ const missing: string[] = [];
64
+ let current = plugins.path.dirname(pathArg);
65
+ while (current !== plugins.path.dirname(current)) {
66
+ try {
67
+ await plugins.fs.promises.lstat(current);
68
+ break;
69
+ } catch (error) {
70
+ if (!isMissing(error)) throw error;
71
+ missing.push(current);
72
+ current = plugins.path.dirname(current);
73
+ }
74
+ }
75
+ return missing;
76
+ };
77
+
78
+ /** Removes the directories, deepest first, as far as they are empty. */
79
+ export const removeEmptyDirectories = async (directoriesArg: readonly string[]): Promise<void> => {
80
+ for (const directory of directoriesArg) {
81
+ try {
82
+ await plugins.fs.promises.rmdir(directory);
83
+ } catch (error) {
84
+ const code = (error as NodeJS.ErrnoException).code;
85
+ if (code === 'ENOTEMPTY' || code === 'EEXIST') return;
86
+ if (!isMissing(error)) throw error;
87
+ }
88
+ }
89
+ };
@@ -1,5 +1,11 @@
1
1
  export { createLocalToolExecutionContext } from './tool.context.js';
2
- export type { ILocalToolExecutionContextOptions } from './tool.context.js';
2
+ export type {
3
+ ILocalToolExecutionContextOptions,
4
+ IToolFileChange,
5
+ IToolFileChangeObserver,
6
+ } from './tool.context.js';
7
+ export { InMemoryWorkspaceReversion } from './classes.workspacereversion.js';
8
+ export type { IInMemoryWorkspaceReversionOptions } from './classes.workspacereversion.js';
3
9
  export { FileToolJobStore } from './tool.persistence.js';
4
10
  export type { IFileToolJobStoreOptions } from './tool.persistence.js';
5
11
  export * from './tools.local.js';