opencode-codex-memory 0.1.9 → 0.2.1
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 +35 -12
- package/dist/opencode.json +4 -4
- package/dist/src/capture.d.ts +29 -4
- package/dist/src/capture.js +108 -57
- package/dist/src/index.d.ts +51 -4
- package/dist/src/index.js +142 -16
- package/dist/src/llm.d.ts +1 -0
- package/dist/src/llm.js +1 -1
- package/dist/src/path-guard.d.ts +9 -0
- package/dist/src/path-guard.js +23 -1
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -3
- package/dist/src/phase1.d.ts +1 -0
- package/dist/src/phase1.js +9 -4
- package/dist/src/phase2.d.ts +2 -0
- package/dist/src/phase2.js +4 -0
- package/dist/src/redact.js +5 -2
- package/dist/src/store.d.ts +5 -0
- package/dist/src/store.js +29 -13
- package/dist/src/workspace.js +8 -3
- package/dist/tools/control.js +40 -14
- package/dist/tools/memory.d.ts +20 -4
- package/dist/tools/memory.js +182 -60
- package/opencode.json +4 -4
- package/package.json +2 -2
package/dist/tools/memory.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { safeResolveMemoryPath } from "../src/path-guard.js";
|
|
4
|
-
import { memoryRoot } from "../src/paths.js";
|
|
3
|
+
import { safeResolveMemoryPath, assertMemoryRootSafe } from "../src/path-guard.js";
|
|
5
4
|
import { tool } from "@opencode-ai/plugin";
|
|
6
5
|
const MAX_READ_BYTES = 256 * 1024;
|
|
7
6
|
export const memory_read = tool({
|
|
@@ -27,19 +26,10 @@ export const memory_read = tool({
|
|
|
27
26
|
metadata: { kind: "directory", entries },
|
|
28
27
|
};
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const size = Math.min(stat.size, MAX_READ_BYTES);
|
|
35
|
-
const buf = Buffer.alloc(size);
|
|
36
|
-
fs.readSync(fd, buf, 0, size, 0);
|
|
37
|
-
text = buf.toString("utf8");
|
|
38
|
-
byteTruncated = stat.size > MAX_READ_BYTES;
|
|
39
|
-
}
|
|
40
|
-
finally {
|
|
41
|
-
fs.closeSync(fd);
|
|
42
|
-
}
|
|
29
|
+
// Read the whole file and apply the line window FIRST; the byte cap
|
|
30
|
+
// applies to the WINDOWED output. Capping the raw read used to make
|
|
31
|
+
// lines beyond the first 256 KiB unreachable regardless of line_offset.
|
|
32
|
+
const text = fs.readFileSync(fullPath, "utf8");
|
|
43
33
|
// Line windowing mirrors codex memories/read: 1-indexed offset, bounded
|
|
44
34
|
// line count, and the start line reported so file:line citations work.
|
|
45
35
|
const startLine = args.line_offset ?? 1;
|
|
@@ -54,12 +44,18 @@ export const memory_read = tool({
|
|
|
54
44
|
lines = lines.slice(0, args.max_lines);
|
|
55
45
|
lineTruncated = true;
|
|
56
46
|
}
|
|
57
|
-
|
|
47
|
+
let body = lines.join("\n");
|
|
48
|
+
let byteTruncated = false;
|
|
49
|
+
if (Buffer.byteLength(body, "utf8") > MAX_READ_BYTES) {
|
|
50
|
+
byteTruncated = true;
|
|
51
|
+
// Byte-accurate cut; drop a possibly split trailing multibyte char.
|
|
52
|
+
body = Buffer.from(body, "utf8").subarray(0, MAX_READ_BYTES).toString("utf8").replace(/\uFFFD+$/, "");
|
|
53
|
+
}
|
|
58
54
|
const notes = [];
|
|
59
55
|
if (lineTruncated)
|
|
60
56
|
notes.push(`[stopped after ${args.max_lines} lines; file has ${totalLines}]`);
|
|
61
57
|
if (byteTruncated)
|
|
62
|
-
notes.push(`[truncated
|
|
58
|
+
notes.push(`[output truncated at ${MAX_READ_BYTES} bytes; use line_offset to page]`);
|
|
63
59
|
const header = startLine > 1 ? `[starting at line ${startLine}]\n` : "";
|
|
64
60
|
return {
|
|
65
61
|
output: header + body + (notes.length ? "\n\n" + notes.join("\n") : ""),
|
|
@@ -152,22 +148,22 @@ function parseDateArg(value, endOfDay) {
|
|
|
152
148
|
}
|
|
153
149
|
// Walks every non-hidden, non-symlink file (codex searches all files, not an
|
|
154
150
|
// extension allowlist), in sorted order for deterministic results.
|
|
155
|
-
function collectSearchFiles(
|
|
151
|
+
function collectSearchFiles(start, prefix) {
|
|
156
152
|
const files = [];
|
|
157
|
-
const walk = (dir,
|
|
153
|
+
const walk = (dir, rel) => {
|
|
158
154
|
const entries = visibleEntries(dir).sort((a, b) => a.name.localeCompare(b.name));
|
|
159
155
|
for (const { name, isDir } of entries) {
|
|
160
156
|
const abs = path.join(dir, name);
|
|
161
|
-
const
|
|
157
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
162
158
|
if (isDir) {
|
|
163
|
-
walk(abs,
|
|
159
|
+
walk(abs, relPath);
|
|
164
160
|
}
|
|
165
161
|
else {
|
|
166
|
-
files.push({ rel, abs, ts: fileTimestamp(name) });
|
|
162
|
+
files.push({ rel: relPath, abs, ts: fileTimestamp(name) });
|
|
167
163
|
}
|
|
168
164
|
}
|
|
169
165
|
};
|
|
170
|
-
walk(
|
|
166
|
+
walk(start, prefix);
|
|
171
167
|
return files;
|
|
172
168
|
}
|
|
173
169
|
function firstContentLine(content) {
|
|
@@ -182,26 +178,118 @@ function firstContentLine(content) {
|
|
|
182
178
|
}
|
|
183
179
|
return "(empty)";
|
|
184
180
|
}
|
|
181
|
+
// --- search engine, ported from codex ext/memories/src/local/search.rs ---
|
|
182
|
+
const SEARCH_MAX_RESULTS = 200; // codex MAX_SEARCH_RESULTS (= default)
|
|
183
|
+
// codex SearchComparison::prepare: lowercase when case-insensitive; when
|
|
184
|
+
// normalized, keep ONLY alphanumeric characters (Unicode) so "blue-green",
|
|
185
|
+
// "blue green" and "bluegreen" compare equal.
|
|
186
|
+
function prepareComparable(value, caseSensitive, normalized) {
|
|
187
|
+
let v = caseSensitive ? value : value.toLowerCase();
|
|
188
|
+
if (normalized)
|
|
189
|
+
v = v.replace(/[^\p{L}\p{N}]/gu, "");
|
|
190
|
+
return v;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Per-file matching, all three codex modes. Window mode extends from every
|
|
194
|
+
* line matching at least one query until all queries are covered (bounded by
|
|
195
|
+
* lineCount), then drops windows that strictly contain another window so only
|
|
196
|
+
* minimal windows are reported.
|
|
197
|
+
*/
|
|
198
|
+
function searchFileContent(file, lines, queries, preparedQueries, mode, lineCount, contextLines, caseSensitive, normalized, out) {
|
|
199
|
+
const lineFlags = lines.map((line) => {
|
|
200
|
+
const prepared = prepareComparable(line, caseSensitive, normalized);
|
|
201
|
+
return preparedQueries.map((q) => prepared.includes(q));
|
|
202
|
+
});
|
|
203
|
+
const matchedQueries = (flags) => queries.filter((_, i) => flags[i]);
|
|
204
|
+
const push = (start, end, flags) => {
|
|
205
|
+
const contentStart = Math.max(0, start - contextLines);
|
|
206
|
+
const contentEnd = Math.min(lines.length, end + contextLines + 1);
|
|
207
|
+
out.push({
|
|
208
|
+
path: file.rel,
|
|
209
|
+
match_line_number: start + 1,
|
|
210
|
+
content_start_line_number: contentStart + 1,
|
|
211
|
+
content: lines.slice(contentStart, contentEnd).join("\n"),
|
|
212
|
+
matched_queries: matchedQueries(flags),
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
if (mode === "any" || mode === "all_on_same_line") {
|
|
216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
217
|
+
const flags = lineFlags[i];
|
|
218
|
+
const hit = mode === "any" ? flags.some(Boolean) : flags.every(Boolean);
|
|
219
|
+
if (hit)
|
|
220
|
+
push(i, i, flags);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// all_within_lines
|
|
225
|
+
const windows = [];
|
|
226
|
+
for (let start = 0; start < lines.length; start++) {
|
|
227
|
+
if (!lineFlags[start].some(Boolean))
|
|
228
|
+
continue;
|
|
229
|
+
const lastAllowed = Math.min(start + lineCount - 1, lines.length - 1);
|
|
230
|
+
const flags = new Array(preparedQueries.length).fill(false);
|
|
231
|
+
for (let end = start; end <= lastAllowed; end++) {
|
|
232
|
+
for (let q = 0; q < flags.length; q++)
|
|
233
|
+
flags[q] = flags[q] || lineFlags[end][q];
|
|
234
|
+
if (flags.every(Boolean)) {
|
|
235
|
+
windows.push({ start, end, flags });
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (let i = 0; i < windows.length; i++) {
|
|
241
|
+
const w = windows[i];
|
|
242
|
+
const containsAnother = windows.some((o, j) => i !== j && w.start <= o.start && w.end >= o.end && (w.start !== o.start || w.end !== o.end));
|
|
243
|
+
if (containsAnother)
|
|
244
|
+
continue;
|
|
245
|
+
push(w.start, w.end, w.flags);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function renderMatch(m) {
|
|
249
|
+
const header = `${m.path}:${m.match_line_number}`;
|
|
250
|
+
if (!m.content.includes("\n") && m.content_start_line_number === m.match_line_number) {
|
|
251
|
+
return `${header}: ${m.content}`;
|
|
252
|
+
}
|
|
253
|
+
return `${header} (content from line ${m.content_start_line_number}):\n${m.content}`;
|
|
254
|
+
}
|
|
185
255
|
export const memory_search = tool({
|
|
186
|
-
description: "Search
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
256
|
+
description: "Search the persistent memory workspace (MEMORY.md, rollout_summaries/*, skills/*) for substring " +
|
|
257
|
+
"matches. Supports multiple queries with match_mode: 'any' (a line matching any query), " +
|
|
258
|
+
"'all_on_same_line' (a line containing every query), or 'all_within_lines' (all queries within a " +
|
|
259
|
+
"window of line_count lines). Optional path scoping, context lines, and cursor pagination. " +
|
|
260
|
+
"Optional since/until restrict the search to time-anchored files (rollout summaries, ad-hoc " +
|
|
261
|
+
"notes) from that period — useful to recall what the user was working on around a given time. " +
|
|
262
|
+
"With since/until and no queries, returns a chronological listing of that period's sessions/notes.",
|
|
191
263
|
args: {
|
|
192
|
-
|
|
264
|
+
queries: tool.schema.array(tool.schema.string()).optional().describe("Search substrings (at least one, non-empty after trim). Optional only when since/until is set."),
|
|
265
|
+
match_mode: tool.schema.enum(["any", "all_on_same_line", "all_within_lines"]).default("any").describe("How multiple queries combine (default any)."),
|
|
266
|
+
line_count: tool.schema.number().int().min(1).optional().describe("Window size in lines for all_within_lines (required for that mode)."),
|
|
267
|
+
path: tool.schema.string().optional().describe("Restrict the search to a file or directory relative to the memory root."),
|
|
268
|
+
cursor: tool.schema.string().optional().describe("Pagination cursor from a previous response's next_cursor."),
|
|
269
|
+
context_lines: tool.schema.number().int().min(0).default(0).describe("Extra lines of context around each match."),
|
|
193
270
|
case_sensitive: tool.schema.boolean().default(true).describe("Case-sensitive matching (default true, like codex memories/search)."),
|
|
271
|
+
normalized: tool.schema.boolean().default(false).describe("Compare only alphanumeric characters, ignoring separators (blue-green == bluegreen); combine with case_sensitive=false to also ignore case."),
|
|
194
272
|
since: tool.schema.string().optional().describe("Only time-anchored files at/after this time (YYYY-MM-DD or ISO datetime)."),
|
|
195
273
|
until: tool.schema.string().optional().describe("Only time-anchored files at/before this time (YYYY-MM-DD or ISO datetime; whole day for date-only)."),
|
|
196
|
-
|
|
274
|
+
max_results: tool.schema.number().int().min(1).max(SEARCH_MAX_RESULTS).default(SEARCH_MAX_RESULTS).describe("Max matches per page (default/max 200, like codex)."),
|
|
197
275
|
},
|
|
198
276
|
async execute(args, ctx) {
|
|
199
277
|
try {
|
|
200
|
-
|
|
278
|
+
// Walks from the root directly (no per-path resolution), so the root
|
|
279
|
+
// symlink check must run here explicitly.
|
|
280
|
+
const root = assertMemoryRootSafe();
|
|
201
281
|
if (!fs.existsSync(root))
|
|
202
282
|
return { output: "Memory workspace is empty." };
|
|
203
|
-
|
|
204
|
-
|
|
283
|
+
const queries = (args.queries ?? []).map((q) => q.trim());
|
|
284
|
+
if (args.queries && (queries.length === 0 || queries.some((q) => q.length === 0))) {
|
|
285
|
+
return { output: "memory_search error: queries must be non-empty strings." };
|
|
286
|
+
}
|
|
287
|
+
if (queries.length === 0 && !args.since && !args.until) {
|
|
288
|
+
return { output: "memory_search error: provide queries and/or since/until." };
|
|
289
|
+
}
|
|
290
|
+
const mode = (args.match_mode ?? "any");
|
|
291
|
+
if (mode === "all_within_lines" && !(typeof args.line_count === "number" && args.line_count >= 1)) {
|
|
292
|
+
return { output: "memory_search error: all_within_lines requires line_count >= 1." };
|
|
205
293
|
}
|
|
206
294
|
const since = args.since ? parseDateArg(args.since, false) : null;
|
|
207
295
|
if (args.since && since === null)
|
|
@@ -209,7 +297,23 @@ export const memory_search = tool({
|
|
|
209
297
|
const until = args.until ? parseDateArg(args.until, true) : null;
|
|
210
298
|
if (args.until && until === null)
|
|
211
299
|
return { output: `memory_search error: could not parse until="${args.until}".` };
|
|
212
|
-
|
|
300
|
+
// Path scoping: a file searches just that file, a directory is walked.
|
|
301
|
+
let files;
|
|
302
|
+
if (args.path) {
|
|
303
|
+
const start = safeResolveMemoryPath(args.path);
|
|
304
|
+
let st;
|
|
305
|
+
try {
|
|
306
|
+
st = fs.statSync(start);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return { output: `Not found: ${args.path}` };
|
|
310
|
+
}
|
|
311
|
+
const rel = args.path.replace(/\/+$/, "");
|
|
312
|
+
files = st.isFile() ? [{ rel, abs: start, ts: fileTimestamp(path.basename(start)) }] : collectSearchFiles(start, rel);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
files = collectSearchFiles(root, "");
|
|
316
|
+
}
|
|
213
317
|
const timeFiltered = since !== null || until !== null;
|
|
214
318
|
if (timeFiltered) {
|
|
215
319
|
// Time filters only apply to time-anchored files; MEMORY.md etc. carry
|
|
@@ -218,8 +322,8 @@ export const memory_search = tool({
|
|
|
218
322
|
files.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0));
|
|
219
323
|
}
|
|
220
324
|
const rangeLabel = timeFiltered ? ` in ${args.since ?? "..."}..${args.until ?? "..."}` : "";
|
|
221
|
-
if (
|
|
222
|
-
const listing = files.slice(0, args.
|
|
325
|
+
if (queries.length === 0) {
|
|
326
|
+
const listing = files.slice(0, args.max_results).map((f) => {
|
|
223
327
|
let content = "";
|
|
224
328
|
try {
|
|
225
329
|
content = fs.readFileSync(f.abs, "utf8");
|
|
@@ -236,13 +340,14 @@ export const memory_search = tool({
|
|
|
236
340
|
};
|
|
237
341
|
}
|
|
238
342
|
const caseSensitive = args.case_sensitive ?? true;
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
343
|
+
const normalized = args.normalized ?? false;
|
|
344
|
+
const preparedQueries = queries.map((q) => prepareComparable(q, caseSensitive, normalized));
|
|
345
|
+
if (preparedQueries.some((q) => q.length === 0)) {
|
|
346
|
+
return { output: "memory_search error: a query is empty after normalization." };
|
|
347
|
+
}
|
|
348
|
+
// codex: collect ALL matches, sort by (path, line), then page by cursor.
|
|
349
|
+
const all = [];
|
|
243
350
|
for (const f of files) {
|
|
244
|
-
if (matches.length >= args.limit)
|
|
245
|
-
break;
|
|
246
351
|
let content;
|
|
247
352
|
try {
|
|
248
353
|
content = fs.readFileSync(f.abs, "utf8");
|
|
@@ -250,26 +355,42 @@ export const memory_search = tool({
|
|
|
250
355
|
catch {
|
|
251
356
|
continue;
|
|
252
357
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
358
|
+
if (content.includes("\u0000"))
|
|
359
|
+
continue; // binary, like codex's InvalidData skip
|
|
360
|
+
searchFileContent(f, content.split(/\r?\n/), queries, preparedQueries, mode, args.line_count ?? 1, args.context_lines ?? 0, caseSensitive, normalized, all);
|
|
361
|
+
}
|
|
362
|
+
all.sort((a, b) => a.path.localeCompare(b.path) || a.match_line_number - b.match_line_number);
|
|
363
|
+
let startIndex = 0;
|
|
364
|
+
if (args.cursor !== undefined) {
|
|
365
|
+
startIndex = Number.parseInt(args.cursor, 10);
|
|
366
|
+
if (!Number.isInteger(startIndex) || startIndex < 0 || String(startIndex) !== args.cursor.trim()) {
|
|
367
|
+
return { output: `memory_search error: invalid cursor "${args.cursor}" (must be a non-negative integer).` };
|
|
368
|
+
}
|
|
369
|
+
if (startIndex > all.length) {
|
|
370
|
+
return { output: `memory_search error: cursor ${startIndex} exceeds result count ${all.length}.` };
|
|
260
371
|
}
|
|
261
372
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const capped = matches.length >= args.limit;
|
|
373
|
+
const endIndex = Math.min(startIndex + (args.max_results ?? SEARCH_MAX_RESULTS), all.length);
|
|
374
|
+
const pageMatches = all.slice(startIndex, endIndex);
|
|
375
|
+
const nextCursor = endIndex < all.length ? String(endIndex) : null;
|
|
376
|
+
const truncated = nextCursor !== null;
|
|
377
|
+
const label = queries.map((q) => `"${q}"`).join(", ");
|
|
378
|
+
if (all.length === 0)
|
|
379
|
+
return { output: `No matches for ${label}${rangeLabel}.` };
|
|
270
380
|
return {
|
|
271
|
-
output: `${
|
|
272
|
-
|
|
381
|
+
output: `${pageMatches.length} of ${all.length} match(es) for ${label}${rangeLabel}` +
|
|
382
|
+
`${truncated ? ` (more available; pass cursor=${nextCursor})` : ""}:\n` +
|
|
383
|
+
pageMatches.map(renderMatch).join("\n"),
|
|
384
|
+
metadata: {
|
|
385
|
+
queries,
|
|
386
|
+
match_mode: mode === "all_within_lines" ? { type: mode, line_count: args.line_count } : { type: mode },
|
|
387
|
+
path: args.path,
|
|
388
|
+
matches: pageMatches,
|
|
389
|
+
next_cursor: nextCursor,
|
|
390
|
+
truncated,
|
|
391
|
+
since: args.since,
|
|
392
|
+
until: args.until,
|
|
393
|
+
},
|
|
273
394
|
};
|
|
274
395
|
}
|
|
275
396
|
catch (err) {
|
|
@@ -287,7 +408,8 @@ export const memory_add_note = tool({
|
|
|
287
408
|
},
|
|
288
409
|
async execute(args, ctx) {
|
|
289
410
|
try {
|
|
290
|
-
|
|
411
|
+
// Writes under the root without per-path resolution; check the root.
|
|
412
|
+
const root = assertMemoryRootSafe();
|
|
291
413
|
const notesDir = path.join(root, NOTES_DIR);
|
|
292
414
|
fs.mkdirSync(notesDir, { recursive: true });
|
|
293
415
|
const ts = new Date().toISOString();
|
package/opencode.json
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"memorize-extract": {
|
|
22
22
|
"mode": "subagent",
|
|
23
|
-
"prompt": "You are a memory extraction agent.
|
|
23
|
+
"prompt": "You are a memory extraction agent. The session transcript is provided inline in the prompt. Extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
|
|
24
24
|
"permission": {
|
|
25
25
|
"*": "deny",
|
|
26
26
|
"bash": "deny",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"websearch": "deny",
|
|
29
29
|
"task": "deny",
|
|
30
30
|
"todowrite": "deny",
|
|
31
|
-
"read": "
|
|
31
|
+
"read": "deny",
|
|
32
32
|
"write": "deny",
|
|
33
33
|
"edit": "deny",
|
|
34
|
-
"glob": "
|
|
35
|
-
"grep": "
|
|
34
|
+
"glob": "deny",
|
|
35
|
+
"grep": "deny"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-codex-memory",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Persistent memory plugin for opencode — ports codex's two-phase memory system (extraction → consolidation → injection → citation feedback)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"license": "Apache-2.0",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opencode-ai/plugin": "^1.
|
|
48
|
+
"@opencode-ai/plugin": "^1.18.0",
|
|
49
49
|
"diff": "^9.0.0",
|
|
50
50
|
"isomorphic-git": "^1.38.6"
|
|
51
51
|
},
|