ravensight-playtest 0.2.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/package.json +1 -1
- package/src/commands/fix.js +8 -2
- package/src/dashboard.js +15 -5
- package/src/run/fixLoop.js +73 -14
- package/src/run/repoTools.js +98 -6
package/README.md
CHANGED
|
@@ -367,7 +367,10 @@ In your repo:
|
|
|
367
367
|
```
|
|
368
368
|
|
|
369
369
|
A `fix` job's run directory holds `changeset.json`, `report.md` (the pull
|
|
370
|
-
request body) and `usage.json`, plus the screenshots it was shown as evidence
|
|
370
|
+
request body) and `usage.json`, plus the screenshots it was shown as evidence,
|
|
371
|
+
and a local `transcript.jsonl` of the apply loop (which tool, on what, how
|
|
372
|
+
much came back) that is never uploaded and is how an attempt that wrote
|
|
373
|
+
nothing gets explained afterwards.
|
|
371
374
|
|
|
372
375
|
Add `.ravensight/jobs/` to your `.gitignore`; `init` does it for you when you
|
|
373
376
|
already have one.
|
package/package.json
CHANGED
package/src/commands/fix.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { optionsFrom } from '../run/args.js';
|
|
5
5
|
import { getDeps } from '../run/deps.js';
|
|
@@ -443,7 +443,13 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
443
443
|
ledger,
|
|
444
444
|
log,
|
|
445
445
|
onProgress: progress => {
|
|
446
|
-
if (options.verbose)
|
|
446
|
+
if (options.verbose) {
|
|
447
|
+
log(` ${progress.tool} ${progress.target || ''} (${progress.resultChars} chars${progress.isError ? ', error' : ''}${progress.repeat ? ', repeat' : ''})`);
|
|
448
|
+
}
|
|
449
|
+
// A local transcript of the apply loop: which tools, on what, how much
|
|
450
|
+
// came back. Never uploaded; it is how a fix that wrote nothing gets
|
|
451
|
+
// explained afterwards, which the first production attempt could not be.
|
|
452
|
+
appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
|
|
447
453
|
},
|
|
448
454
|
shouldStop: () => interrupt.signalled || heartbeat.canceled
|
|
449
455
|
});
|
package/src/dashboard.js
CHANGED
|
@@ -36,24 +36,34 @@ export function dashboardUrl(apiUrl = resolveApiUrl()) {
|
|
|
36
36
|
return url.toString().replace(/\/+$/, '');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The dashboard's playtest routes carry no game in the path: the switcher
|
|
41
|
+
* holds the game, and a link names it in the query so the router can select
|
|
42
|
+
* it on arrival. `/games/<id>/playtest/...` was the first cut and answered
|
|
43
|
+
* "That page does not exist" for every link this CLI printed.
|
|
44
|
+
*/
|
|
45
|
+
function playtestPage(gameId, page, apiUrl) {
|
|
46
|
+
return `${dashboardUrl(apiUrl)}/playtest${page}?game=${encodeURIComponent(gameId)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** The dashboard page for one game's playtest surface. */
|
|
40
50
|
export function gameLink(gameId, apiUrl) {
|
|
41
|
-
return
|
|
51
|
+
return playtestPage(gameId, '', apiUrl);
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
/** The dashboard page for one job. Matches `links.dashboard` from POST /jobs. */
|
|
45
55
|
export function jobLink(gameId, jobId, apiUrl) {
|
|
46
|
-
return
|
|
56
|
+
return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}`, apiUrl);
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
/** The post job review form for one job. */
|
|
50
60
|
export function reviewLink(gameId, jobId, apiUrl) {
|
|
51
|
-
return
|
|
61
|
+
return playtestPage(gameId, `/jobs/${encodeURIComponent(jobId)}/review`, apiUrl);
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/** The brief form. */
|
|
55
65
|
export function briefLink(gameId, apiUrl) {
|
|
56
|
-
return
|
|
66
|
+
return playtestPage(gameId, '/brief', apiUrl);
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/**
|
|
@@ -63,7 +73,7 @@ export function briefLink(gameId, apiUrl) {
|
|
|
63
73
|
* server can keep true.
|
|
64
74
|
*/
|
|
65
75
|
export function trustLink(gameId, apiUrl) {
|
|
66
|
-
return
|
|
76
|
+
return playtestPage(gameId, '/what-leaves-your-machine', apiUrl);
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/**
|
package/src/run/fixLoop.js
CHANGED
|
@@ -37,6 +37,32 @@ export const SUMMARY_MAX_TOKENS = 4096;
|
|
|
37
37
|
*/
|
|
38
38
|
export const MAX_FIX_ACTIONS = 60;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* How many tool-answering turns the plan step may take before it has to
|
|
42
|
+
* answer. A real codebase takes several reads to locate a behavior; one
|
|
43
|
+
* repair turn (the first cut) produced empty plans on every real repository.
|
|
44
|
+
*/
|
|
45
|
+
export const PLAN_MAX_TURNS = 6;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every this-many apply turns without a write, the loop tells the model how
|
|
49
|
+
* many actions are left and asks it to write or say what blocks it. A model
|
|
50
|
+
* that is lost otherwise reads until the budget warning ends the run.
|
|
51
|
+
*/
|
|
52
|
+
export const NUDGE_EVERY = 8;
|
|
53
|
+
|
|
54
|
+
/** The text of a tool result, whichever of the two content shapes it uses. */
|
|
55
|
+
function resultText(result) {
|
|
56
|
+
if (!result) return '';
|
|
57
|
+
if (typeof result.content === 'string') return result.content;
|
|
58
|
+
if (Array.isArray(result.content)) return result.content.map(block => (block && block.text) || '').join('');
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resultLength(result) {
|
|
63
|
+
return resultText(result).length;
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/** A plan that says nothing, which is what a malformed answer becomes. */
|
|
41
67
|
export const EMPTY_PLAN = Object.freeze({ hypothesis: '', files_to_change: [], out_of_scope: [] });
|
|
42
68
|
|
|
@@ -176,7 +202,11 @@ export async function planFix({
|
|
|
176
202
|
let repaired = false;
|
|
177
203
|
let plan = null;
|
|
178
204
|
|
|
179
|
-
|
|
205
|
+
// Reading turns are answered and counted; a turn with no tool calls is
|
|
206
|
+
// the answer, and one repair is allowed when that answer is not JSON. At
|
|
207
|
+
// the ceiling the model is told to answer now, and if it still reaches for
|
|
208
|
+
// a tool the plan stays empty: planning is not where the budget belongs.
|
|
209
|
+
while (turns < PLAN_MAX_TURNS + 1 && !plan) {
|
|
180
210
|
const answer = await callStepWithRetry({
|
|
181
211
|
client,
|
|
182
212
|
jobId,
|
|
@@ -196,9 +226,13 @@ export async function planFix({
|
|
|
196
226
|
plan = normalisePlan(parsed);
|
|
197
227
|
break;
|
|
198
228
|
}
|
|
199
|
-
|
|
229
|
+
const reading = answer.toolUses.length > 0 && Boolean(repo);
|
|
230
|
+
if (!reading) {
|
|
231
|
+
if (repaired) break;
|
|
232
|
+
repaired = true;
|
|
233
|
+
}
|
|
234
|
+
if (turns > PLAN_MAX_TURNS) break;
|
|
200
235
|
|
|
201
|
-
repaired = true;
|
|
202
236
|
messages.push({
|
|
203
237
|
role: 'assistant',
|
|
204
238
|
content: [
|
|
@@ -212,16 +246,15 @@ export async function planFix({
|
|
|
212
246
|
const executed = await repo.execute(toolUse);
|
|
213
247
|
results.push(executed.result);
|
|
214
248
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
});
|
|
249
|
+
const nudge = turns >= PLAN_MAX_TURNS
|
|
250
|
+
? 'No more reading. Answer now with the JSON object only, nothing before or after it.'
|
|
251
|
+
: reading
|
|
252
|
+
? `${PLAN_MAX_TURNS - turns} more reading turn(s) before you have to answer with the JSON object.`
|
|
253
|
+
: 'That was not the plan object. Answer with the JSON object only, nothing before or after it.';
|
|
254
|
+
messages.push({ role: 'user', content: [...results, { type: 'text', text: nudge }] });
|
|
222
255
|
}
|
|
223
256
|
|
|
224
|
-
if (!plan) log(
|
|
257
|
+
if (!plan) log(`The plan did not come back as JSON within ${PLAN_MAX_TURNS} reading turns and one repair, so the fix proceeds without one.`);
|
|
225
258
|
return { plan: plan || { ...EMPTY_PLAN }, turns, repaired, messages };
|
|
226
259
|
}
|
|
227
260
|
|
|
@@ -275,7 +308,7 @@ export async function applyFix({
|
|
|
275
308
|
content: [{
|
|
276
309
|
type: 'text',
|
|
277
310
|
text: [
|
|
278
|
-
'Make the change now. Read before you
|
|
311
|
+
'Make the change now. Read before you edit. Change an existing file with `edit_file` (old_text copied verbatim, new_text in its place); use `write_file` only for a new file, because it replaces the whole file and a large file will not fit in one answer.',
|
|
279
312
|
'Change as little as possible, keep the style of the code around it, and stop as soon as the fix is in. When you are done, say in one line what you changed and call no more tools.',
|
|
280
313
|
'',
|
|
281
314
|
`<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
|
|
@@ -286,6 +319,10 @@ export async function applyFix({
|
|
|
286
319
|
|
|
287
320
|
let turns = 0;
|
|
288
321
|
let stop = 'max_actions';
|
|
322
|
+
// Every call the loop has already answered, so a lost model that asks the
|
|
323
|
+
// same question twice is told so instead of paying for the same answer.
|
|
324
|
+
const answered = new Map();
|
|
325
|
+
let turnsSinceWrite = 0;
|
|
289
326
|
while (turns < maxActions) {
|
|
290
327
|
if (shouldStop()) {
|
|
291
328
|
stop = 'interrupted';
|
|
@@ -328,10 +365,32 @@ export async function applyFix({
|
|
|
328
365
|
]
|
|
329
366
|
});
|
|
330
367
|
const results = [];
|
|
368
|
+
let wroteThisTurn = false;
|
|
331
369
|
for (const toolUse of answer.toolUses) {
|
|
370
|
+
const key = `${toolUse.name}:${JSON.stringify(toolUse.input || {})}`;
|
|
371
|
+
const repeat = answered.get(key);
|
|
332
372
|
const executed = await repo.execute(toolUse);
|
|
333
|
-
|
|
334
|
-
|
|
373
|
+
const result = repeat === undefined
|
|
374
|
+
? executed.result
|
|
375
|
+
: { ...executed.result, content: [{ type: 'text', text: `(identical to a call you already made on turn ${repeat}; the answer has not changed)\n${resultText(executed.result)}` }] };
|
|
376
|
+
answered.set(key, repeat === undefined ? turns : repeat);
|
|
377
|
+
results.push(result);
|
|
378
|
+
if ((toolUse.name === 'write_file' || toolUse.name === 'edit_file') && !executed.result.is_error) wroteThisTurn = true;
|
|
379
|
+
onProgress({
|
|
380
|
+
turn: turns,
|
|
381
|
+
tool: toolUse.name,
|
|
382
|
+
target: toolUse.input && (toolUse.input.path || toolUse.input.pattern),
|
|
383
|
+
resultChars: resultLength(executed.result),
|
|
384
|
+
isError: Boolean(executed.result.is_error),
|
|
385
|
+
repeat: repeat !== undefined
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
turnsSinceWrite = wroteThisTurn ? 0 : turnsSinceWrite + 1;
|
|
389
|
+
if (turnsSinceWrite > 0 && turnsSinceWrite % NUDGE_EVERY === 0 && turns < maxActions) {
|
|
390
|
+
results.push({
|
|
391
|
+
type: 'text',
|
|
392
|
+
text: `You have used ${turns} of ${maxActions} actions and written nothing. Either make the change now with edit_file (or write_file for a new file), or answer in one line, with no tool call, why it cannot be made.`
|
|
393
|
+
});
|
|
335
394
|
}
|
|
336
395
|
messages.push({ role: 'user', content: results });
|
|
337
396
|
|
package/src/run/repoTools.js
CHANGED
|
@@ -68,7 +68,13 @@ export const MAX_READ_BYTES = 120000;
|
|
|
68
68
|
export const MAX_GLOB_RESULTS = 400;
|
|
69
69
|
|
|
70
70
|
/** How many matching lines a grep answers with. */
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Sixty, not two hundred: at three hundred characters a line the old cap
|
|
73
|
+
* let one grep put 60 KB into the context, and forty greps in one apply loop
|
|
74
|
+
* ate the whole model budget without a write. A capped answer says so, so
|
|
75
|
+
* the model narrows rather than repeats.
|
|
76
|
+
*/
|
|
77
|
+
export const MAX_GREP_RESULTS = 60;
|
|
72
78
|
|
|
73
79
|
/** The tool definitions for `game_profile.triage`. */
|
|
74
80
|
export const PROFILE_TOOLS = Object.freeze([
|
|
@@ -126,10 +132,34 @@ export const MAX_WRITE_BYTES = 200000;
|
|
|
126
132
|
/** How many distinct paths one run may write. A fix is a patch, not a rewrite. */
|
|
127
133
|
export const MAX_WRITES_PER_RUN = 20;
|
|
128
134
|
|
|
129
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* The two writing tools, defined here so nothing else can invent a third.
|
|
137
|
+
*
|
|
138
|
+
* `edit_file` exists because whole-file writes do not survive contact with a
|
|
139
|
+
* real codebase: the first production fix attempt read an 84 KB C++ file and
|
|
140
|
+
* then could not send it back inside the step's output ceiling, so its one
|
|
141
|
+
* write arrived as malformed input and the attempt ended with nothing on the
|
|
142
|
+
* branch. An exact-text replacement is a few hundred bytes for the same edit.
|
|
143
|
+
*/
|
|
144
|
+
export const EDIT_FILE_TOOL = Object.freeze({
|
|
145
|
+
name: 'edit_file',
|
|
146
|
+
description: 'Replace one exact passage of a text file in the repository with new text. Paths are relative to the repository root. old_text must appear exactly once in the file (or set replace_all for every occurrence); copy it verbatim from read_file, whitespace included. This is the tool for changing a file that already exists.',
|
|
147
|
+
input_schema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
required: ['path', 'old_text', 'new_text'],
|
|
151
|
+
properties: {
|
|
152
|
+
path: { type: 'string' },
|
|
153
|
+
old_text: { type: 'string' },
|
|
154
|
+
new_text: { type: 'string' },
|
|
155
|
+
replace_all: { type: 'boolean' }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
130
160
|
export const WRITE_FILE_TOOL = Object.freeze({
|
|
131
161
|
name: 'write_file',
|
|
132
|
-
description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root.
|
|
162
|
+
description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root. Use it for a new file or a small one; for a change to an existing file use edit_file, because the content you send here is the whole file.',
|
|
133
163
|
input_schema: {
|
|
134
164
|
type: 'object',
|
|
135
165
|
additionalProperties: false,
|
|
@@ -141,8 +171,8 @@ export const WRITE_FILE_TOOL = Object.freeze({
|
|
|
141
171
|
}
|
|
142
172
|
});
|
|
143
173
|
|
|
144
|
-
/** The tool definitions for `fix.apply`: the read-only three, plus the
|
|
145
|
-
export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, WRITE_FILE_TOOL]);
|
|
174
|
+
/** The tool definitions for `fix.apply`: the read-only three, plus the two writes. */
|
|
175
|
+
export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, EDIT_FILE_TOOL, WRITE_FILE_TOOL]);
|
|
146
176
|
|
|
147
177
|
/**
|
|
148
178
|
* Compile a glob into a regular expression. Supports `**`, `*` and `?`,
|
|
@@ -388,6 +418,14 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
388
418
|
const left = MAX_WRITES_PER_RUN - written.size;
|
|
389
419
|
return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
|
|
390
420
|
}
|
|
421
|
+
if (toolUse.name === 'edit_file' && canWrite) {
|
|
422
|
+
const planned = await planEdit(root, input, written);
|
|
423
|
+
if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
|
|
424
|
+
await doWrite(planned.absolute, planned.content);
|
|
425
|
+
written.add(planned.relative);
|
|
426
|
+
const left = MAX_WRITES_PER_RUN - written.size;
|
|
427
|
+
return { result: ok(toolUse.id, `Edited ${planned.relative}: ${planned.replaced} occurrence(s) replaced, now ${planned.bytes} bytes. ${left} more file(s) may be written in this run.`) };
|
|
428
|
+
}
|
|
391
429
|
if (toolUse.name === 'glob') {
|
|
392
430
|
const matcher = globToRegExp(input.pattern);
|
|
393
431
|
const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
|
|
@@ -495,6 +533,54 @@ export async function planWrite(root, input, written = new Set()) {
|
|
|
495
533
|
* @param {string} absolute
|
|
496
534
|
* @param {string} content
|
|
497
535
|
*/
|
|
536
|
+
/**
|
|
537
|
+
* Everything `planWrite` checks about the path, then the replacement itself:
|
|
538
|
+
* the file has to exist and be text, `old_text` has to be found exactly once
|
|
539
|
+
* unless `replace_all`, and the result has to fit the same byte ceiling a
|
|
540
|
+
* whole write does. Refusals name the count found, because "not found" and
|
|
541
|
+
* "found three times" call for different next moves.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} root
|
|
544
|
+
* @param {Object} input
|
|
545
|
+
* @param {Set<string>} written
|
|
546
|
+
* @returns {Promise<{refusal: string}|{relative: string, absolute: string, content: string, bytes: number, replaced: number}>}
|
|
547
|
+
*/
|
|
548
|
+
export async function planEdit(root, input, written = new Set()) {
|
|
549
|
+
const oldText = input && input.old_text;
|
|
550
|
+
const newText = input && input.new_text;
|
|
551
|
+
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
552
|
+
return { refusal: 'Refused: edit_file needs old_text, the exact passage to replace, copied verbatim from read_file.' };
|
|
553
|
+
}
|
|
554
|
+
if (typeof newText !== 'string') return { refusal: 'Refused: new_text has to be a string (it may be empty to delete the passage).' };
|
|
555
|
+
// The path rules are the write rules; the placeholder content only has to be
|
|
556
|
+
// a string so the shared checks run.
|
|
557
|
+
const planned = await planWrite(root, { path: input && input.path, content: '' }, written);
|
|
558
|
+
if (planned.refusal) return { refusal: planned.refusal.replace('write_file', 'edit_file') };
|
|
559
|
+
|
|
560
|
+
let current;
|
|
561
|
+
try {
|
|
562
|
+
current = await readFile(planned.absolute, 'utf8');
|
|
563
|
+
} catch {
|
|
564
|
+
return { refusal: `Refused: ${planned.relative} does not exist yet. Use write_file to create a new file.` };
|
|
565
|
+
}
|
|
566
|
+
if (current.includes(NUL)) return { refusal: `Refused: ${planned.relative} is not a text file.` };
|
|
567
|
+
|
|
568
|
+
const occurrences = current.split(oldText).length - 1;
|
|
569
|
+
if (occurrences === 0) {
|
|
570
|
+
return { refusal: `Refused: old_text was not found in ${planned.relative}. Read the file again and copy the passage exactly, whitespace included.` };
|
|
571
|
+
}
|
|
572
|
+
if (occurrences > 1 && !(input && input.replace_all)) {
|
|
573
|
+
return { refusal: `Refused: old_text appears ${occurrences} times in ${planned.relative}. Include more surrounding lines so it is unique, or set replace_all.` };
|
|
574
|
+
}
|
|
575
|
+
const content = input && input.replace_all ? current.split(oldText).join(newText) : current.replace(oldText, () => newText);
|
|
576
|
+
if (content.includes(NUL)) return { refusal: 'Refused: new_text holds a NUL byte, so that is not text.' };
|
|
577
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
578
|
+
if (bytes > MAX_WRITE_BYTES) {
|
|
579
|
+
return { refusal: `Refused: ${planned.relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}.` };
|
|
580
|
+
}
|
|
581
|
+
return { relative: planned.relative, absolute: planned.absolute, content, bytes, replaced: input && input.replace_all ? occurrences : 1 };
|
|
582
|
+
}
|
|
583
|
+
|
|
498
584
|
async function doWrite(absolute, content) {
|
|
499
585
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
500
586
|
const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -532,7 +618,11 @@ async function doGrep(root, all, input) {
|
|
|
532
618
|
if (regex.test(lines[i])) hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
|
|
533
619
|
}
|
|
534
620
|
}
|
|
535
|
-
|
|
621
|
+
if (hits.length === 0) return '(no matches)';
|
|
622
|
+
const capped = hits.length >= MAX_GREP_RESULTS;
|
|
623
|
+
return capped
|
|
624
|
+
? `${hits.join('\n')}\n(stopped at ${MAX_GREP_RESULTS} matches; narrow the pattern or add a glob)`
|
|
625
|
+
: hits.join('\n');
|
|
536
626
|
}
|
|
537
627
|
|
|
538
628
|
function ok(id, text, isError = false) {
|
|
@@ -544,6 +634,8 @@ export default {
|
|
|
544
634
|
PROFILE_TOOLS,
|
|
545
635
|
READ_ONLY_TOOLS,
|
|
546
636
|
WRITE_TOOLS,
|
|
637
|
+
EDIT_FILE_TOOL,
|
|
638
|
+
planEdit,
|
|
547
639
|
globToRegExp,
|
|
548
640
|
listFiles,
|
|
549
641
|
planWrite,
|