bridgerator 1.0.7 → 1.1.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.
- package/README.md +78 -15
- package/assets/bridgerator.ico +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-20.png +0 -0
- package/assets/icon-24.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/logo.svg +1 -0
- package/install.ps1 +465 -43
- package/npm-shrinkwrap.json +8621 -0
- package/package.json +55 -49
- package/recipes/backup-folder.json +31 -0
- package/recipes/clean-downloads.json +48 -0
- package/recipes/disk-report.json +66 -0
- package/recipes/sort-folder.json +49 -0
- package/recipes/update-apps.json +25 -0
- package/src/ask-lib.ps1 +230 -0
- package/src/ask.mjs +51 -0
- package/src/ask.ps1 +17 -0
- package/src/automations.mjs +673 -0
- package/src/batch.mjs +221 -0
- package/src/bridge.mjs +743 -29
- package/src/cli.mjs +561 -53
- package/src/confirm.mjs +418 -0
- package/src/dc-config-hooks.mjs +18 -8
- package/src/dc-isolate.mjs +5 -0
- package/src/delegate.mjs +555 -0
- package/src/docs-tool.mjs +494 -0
- package/src/doctor.mjs +200 -0
- package/src/events.mjs +21 -0
- package/src/extra-tools.mjs +122 -0
- package/src/guard.mjs +34 -0
- package/src/notice.mjs +86 -23
- package/src/pair.mjs +41 -6
- package/src/pc-check.mjs +112 -0
- package/src/pc-check.ps1 +103 -0
- package/src/pc-text.mjs +94 -0
- package/src/pdf-font.mjs +245 -0
- package/src/photos-tool.mjs +187 -0
- package/src/photos.ps1 +145 -0
- package/src/plan.mjs +90 -0
- package/src/policy.mjs +293 -109
- package/src/queue.mjs +194 -0
- package/src/recent.mjs +48 -0
- package/src/recipes.mjs +108 -0
- package/src/recycle.mjs +185 -0
- package/src/recycle.ps1 +110 -0
- package/src/screen-tool.mjs +185 -0
- package/src/screen.ps1 +285 -0
- package/src/search-tool.mjs +189 -0
- package/src/search.ps1 +129 -0
- package/src/settings-tool.mjs +186 -0
- package/src/setup-tool.mjs +129 -0
- package/src/software-tool.mjs +185 -0
- package/src/telemetry.mjs +86 -0
- package/src/tool-lib.mjs +177 -0
- package/src/tool-text.mjs +45 -0
- package/src/tray.mjs +97 -0
- package/src/tray.ps1 +359 -0
- package/src/undo.mjs +313 -0
- package/src/win.mjs +17 -0
- package/ui/app.css +388 -0
- package/ui/app.js +868 -0
- package/ui/i18n/en.json +598 -0
- package/ui/i18n/ru.json +598 -0
- package/ui/index.html +26 -0
- package/vendor/webview2/LICENSE.txt +27 -0
- package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
- package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/vendor/webview2/NOTICE.txt +85 -0
- package/vendor/webview2/SHA256SUMS.txt +4 -0
- package/vendor/webview2/SOURCE.txt +1 -0
- package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
- package/vendor/webview2/x64/WebView2Loader.dll +0 -0
package/src/undo.mjs
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Snapshots and Undo"). Before the AI writes, edits, moves or
|
|
2
|
+
// (through the Recycle Bin path) deletes a file, a copy goes to %LOCALAPPDATA%\Bridgerator\snapshots
|
|
3
|
+
// (inside the always-excluded folder, never sent anywhere). "Undo" puts the old state back — but never
|
|
4
|
+
// over a file someone changed after the AI's action (size / time differ from what the AI left): that
|
|
5
|
+
// file is skipped and reported. Limits: days, total MB, biggest file MB (a bigger file gets no copy:
|
|
6
|
+
// the action is marked "no undo").
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import cp from 'node:child_process';
|
|
11
|
+
import { writeAtomic } from './confirm.mjs';
|
|
12
|
+
import { POWERSHELL, psArgs } from './win.mjs';
|
|
13
|
+
|
|
14
|
+
export const SNAP_DEFAULTS = { enabled: true, days: 7, maxMb: 500, maxFileMb: 50 };
|
|
15
|
+
const MAX_TREE_FILES = 5000;
|
|
16
|
+
const MB = 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Current state of a path: { kind: 'none' } | { kind: 'file', size, mtimeMs, hash? } | { kind: 'dir' }.
|
|
20
|
+
* Small files also get a content hash: two writes of the same size within one millisecond look alike otherwise.
|
|
21
|
+
*/
|
|
22
|
+
export function sig(p) {
|
|
23
|
+
try {
|
|
24
|
+
const s = fs.lstatSync(p);
|
|
25
|
+
if (s.isDirectory()) return { kind: 'dir' };
|
|
26
|
+
const out = { kind: 'file', size: s.size, mtimeMs: Math.round(s.mtimeMs) };
|
|
27
|
+
if (s.size <= HASH_MAX) { try { out.hash = crypto.createHash('sha1').update(fs.readFileSync(p)).digest('hex'); } catch { /* locked: size + time only */ } }
|
|
28
|
+
return out;
|
|
29
|
+
} catch { return { kind: 'none' }; }
|
|
30
|
+
}
|
|
31
|
+
const HASH_MAX = 1024 * 1024;
|
|
32
|
+
// A pending action's own change lands within this time of its capture (one engine call / recycle).
|
|
33
|
+
// A delegated agent run passes its own time limit (maxMs); an old meta without it gets the longest one.
|
|
34
|
+
const PENDING_MAX_MS = 10 * 60000;
|
|
35
|
+
const DELEGATE_MAX_MS = 240 * 60000;
|
|
36
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
37
|
+
// Windows reuses pids: a pending action of a live pid is taken over only when that process is proven
|
|
38
|
+
// NOT the recorded bridge (its start time differs by more than START_SLACK_MS). Start time unknown
|
|
39
|
+
// (PowerShell slow / timed out / denied) = maybe still that bridge → left alone this start (data first).
|
|
40
|
+
const START_SLACK_MS = 10000;
|
|
41
|
+
const SELF_START = Math.round(Date.now() - process.uptime() * 1000);
|
|
42
|
+
/** Start time (ms) of a process, or null when it cannot be read. Async (execFile, 5 s limit): never blocks the event loop. */
|
|
43
|
+
export function procStart(pid) {
|
|
44
|
+
if (process.platform !== 'win32') return Promise.resolve(null);
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
cp.execFile(POWERSHELL, psArgs(`[DateTimeOffset]::new((Get-Process -Id ${Number(pid)} -ErrorAction Stop).StartTime).ToUnixTimeMilliseconds()`), { encoding: 'utf8', windowsHide: true, timeout: 5000 }, (err, out) => {
|
|
47
|
+
const n = err ? NaN : Number(String(out).trim());
|
|
48
|
+
resolve(Number.isFinite(n) && n > 0 ? n : null);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const same = (a, b) => a && b && a.kind === b.kind && (a.kind !== 'file' || (a.size === b.size && a.mtimeMs === b.mtimeMs && a.hash === b.hash));
|
|
53
|
+
// Own recursive copy: fs.cp / fs.cpSync of Node 24 garble non-ASCII folder names on Windows.
|
|
54
|
+
function copyTree(from, to) {
|
|
55
|
+
fs.mkdirSync(to, { recursive: true });
|
|
56
|
+
for (const e of fs.readdirSync(from, { withFileTypes: true })) {
|
|
57
|
+
if (e.isSymbolicLink()) continue;
|
|
58
|
+
const a = path.join(from, e.name), b = path.join(to, e.name);
|
|
59
|
+
if (e.isDirectory()) copyTree(a, b); else fs.copyFileSync(a, b);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Size of a folder tree, null when over the caps (then no copy).
|
|
64
|
+
function treeSize(dir, capBytes) {
|
|
65
|
+
let bytes = 0, files = 0;
|
|
66
|
+
const stack = [dir];
|
|
67
|
+
while (stack.length) {
|
|
68
|
+
const d = stack.pop();
|
|
69
|
+
let list;
|
|
70
|
+
try { list = fs.readdirSync(d, { withFileTypes: true }); } catch { return null; }
|
|
71
|
+
for (const e of list) {
|
|
72
|
+
const p = path.join(d, e.name);
|
|
73
|
+
if (e.isSymbolicLink()) continue;
|
|
74
|
+
if (e.isDirectory()) { stack.push(p); continue; }
|
|
75
|
+
if (++files > MAX_TREE_FILES) return null;
|
|
76
|
+
try { bytes += fs.statSync(p).size; } catch { return null; }
|
|
77
|
+
if (bytes > capBytes) return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return bytes;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The paths a tool call changes: [{ path, role }] (role 'move' = source of a move_file). */
|
|
84
|
+
export function changedPaths(name, args = {}) {
|
|
85
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
86
|
+
if (name === 'write_file' && typeof a.path === 'string') return [{ path: a.path }];
|
|
87
|
+
if (name === 'edit_block' && typeof a.file_path === 'string') return [{ path: a.file_path }];
|
|
88
|
+
if (name === 'move_file' && typeof a.source === 'string' && typeof a.destination === 'string') return [{ path: a.source, role: 'from' }, { path: a.destination, role: 'to' }];
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createSnapshots(dir, { limits = () => SNAP_DEFAULTS, now = Date.now, log = () => {}, startOf = procStart, selfStart = SELF_START } = {}) {
|
|
93
|
+
let actions = [];
|
|
94
|
+
const touched = new Set(); // paths this process captured (deferred recovery must not take their state)
|
|
95
|
+
const lim = () => ({ ...SNAP_DEFAULTS, ...(limits() || {}) });
|
|
96
|
+
const metaFile = (id) => path.join(dir, id, 'meta.json');
|
|
97
|
+
const save = (a) => { try { writeAtomic(metaFile(a.id), JSON.stringify(a)); } catch (e) { log(`[bridgerator] snapshot ${a.id}: ${e.message}`); } };
|
|
98
|
+
const drop = (a) => { try { fs.rmSync(path.join(dir, a.id), { recursive: true, force: true }); } catch (e) { log(`[bridgerator] snapshot cleanup ${a.id}: ${e.message}`); } };
|
|
99
|
+
const readMeta = (id) => {
|
|
100
|
+
const a = JSON.parse(fs.readFileSync(metaFile(id), 'utf8'));
|
|
101
|
+
if (!a || a.id !== id || !Array.isArray(a.entries)) throw new Error('bad meta');
|
|
102
|
+
return a;
|
|
103
|
+
};
|
|
104
|
+
const rmId = (id) => { try { fs.rmSync(path.join(dir, id), { recursive: true, force: true }); } catch { /* busy */ } };
|
|
105
|
+
// Captured, but the bridge stopped before the commit (crash, power): kept when the files did change.
|
|
106
|
+
// late: recovered after startup (deferred check) — a path this bridge has captured since, or a file
|
|
107
|
+
// changed after this bridge started, may hold a NEWER action's result: its after-state stays unknown.
|
|
108
|
+
const recover = (a, late = false) => {
|
|
109
|
+
if (a.pending) {
|
|
110
|
+
const maxMs = Number.isFinite(a.maxMs) && a.maxMs > 0 ? a.maxMs : a.tool === 'delegate' ? DELEGATE_MAX_MS : PENDING_MAX_MS;
|
|
111
|
+
delete a.pending; delete a.pid; delete a.started; delete a.maxMs;
|
|
112
|
+
// A file changed long after the action (the person, while the bridge was down) is not "what the AI
|
|
113
|
+
// left": its after-state stays unknown, so Undo never puts the old copy over it.
|
|
114
|
+
for (const e of a.entries) {
|
|
115
|
+
const now1 = sig(e.path);
|
|
116
|
+
const newer = late && (touched.has(e.path) || (now1.kind === 'file' && now1.mtimeMs > selfStart));
|
|
117
|
+
e.after = newer || (now1.kind === 'file' && now1.mtimeMs > a.t + maxMs) ? { kind: 'unknown' } : now1;
|
|
118
|
+
}
|
|
119
|
+
if (a.entries.every((e) => same(e.before, e.after))) throw new Error('nothing changed');
|
|
120
|
+
save(a);
|
|
121
|
+
}
|
|
122
|
+
return a;
|
|
123
|
+
};
|
|
124
|
+
// A left-alone pending meta older than the days limit is long over (an action lasts maxMs at most):
|
|
125
|
+
// removed like prune() would, so a pid reused by a long-lived program never leaks its copies.
|
|
126
|
+
const expired = (a) => !(a.t >= now() - lim().days * 86400000);
|
|
127
|
+
const check = []; // pending metas of a live pid with a start time: checked after startup (async)
|
|
128
|
+
try {
|
|
129
|
+
for (const id of fs.readdirSync(dir)) {
|
|
130
|
+
try {
|
|
131
|
+
const a = readMeta(id);
|
|
132
|
+
// Another bridge process still running owns its pending action: left alone (not read, not removed).
|
|
133
|
+
if (a.pending && a.pid && a.pid !== process.pid && alive(a.pid)) {
|
|
134
|
+
if (Number.isFinite(a.started)) check.push(a); // meta of an older bridge (pid only): left alone
|
|
135
|
+
else if (expired(a)) rmId(id);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
actions.push(recover(a));
|
|
139
|
+
} catch { rmId(id); }
|
|
140
|
+
}
|
|
141
|
+
} catch { /* no folder yet */ }
|
|
142
|
+
actions.sort((x, y) => x.t - y.t);
|
|
143
|
+
// Live pid: taken over only when its start time is known AND differs (the pid now belongs to another
|
|
144
|
+
// program). Same start time or unknown = that bridge may still be running → left alone this start.
|
|
145
|
+
const ready = Promise.all(check.map(async (a0) => {
|
|
146
|
+
let st = null;
|
|
147
|
+
try { st = await startOf(a0.pid); } catch { /* unknown */ }
|
|
148
|
+
if (st === null || !Number.isFinite(st) || Math.abs(st - a0.started) <= START_SLACK_MS) { if (expired(a0)) rmId(a0.id); return; }
|
|
149
|
+
try {
|
|
150
|
+
const a = readMeta(a0.id); // re-read: its owner may have committed meanwhile
|
|
151
|
+
if (!a.pending || a.pid !== a0.pid || a.started !== a0.started) return; // changed since: not ours to take
|
|
152
|
+
actions.push(recover(a, true));
|
|
153
|
+
actions.sort((x, y) => x.t - y.t);
|
|
154
|
+
} catch { rmId(a0.id); }
|
|
155
|
+
})).then(() => { if (check.length) prune(); });
|
|
156
|
+
|
|
157
|
+
function prune() {
|
|
158
|
+
const l = lim();
|
|
159
|
+
const old = now() - l.days * 86400000;
|
|
160
|
+
let total = actions.reduce((n, a) => n + (a.bytes || 0), 0);
|
|
161
|
+
const keep = [];
|
|
162
|
+
for (const a of actions) {
|
|
163
|
+
if (a.t < old) { drop(a); total -= a.bytes || 0; continue; }
|
|
164
|
+
keep.push(a);
|
|
165
|
+
}
|
|
166
|
+
while (keep.length && total > l.maxMb * MB) { const a = keep.shift(); drop(a); total -= a.bytes || 0; }
|
|
167
|
+
actions = keep;
|
|
168
|
+
}
|
|
169
|
+
prune();
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
/**
|
|
173
|
+
* Copies the current state of the paths before the action. → a pending action (commit after the
|
|
174
|
+
* call), or null when snapshots are off / nothing to copy. kind 'delete' = paths about to be removed.
|
|
175
|
+
*/
|
|
176
|
+
async capture(tool, list, { task, label, keepEmpty = false, maxMs } = {}) {
|
|
177
|
+
const l = lim();
|
|
178
|
+
// keepEmpty: a delegated agent run in an empty folder still needs an action (its new files are added at commit).
|
|
179
|
+
if (!l.enabled || (!list.length && !keepEmpty)) return null;
|
|
180
|
+
const id = `${now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
|
|
181
|
+
const base = path.join(dir, id);
|
|
182
|
+
fs.mkdirSync(base, { recursive: true });
|
|
183
|
+
const entries = [];
|
|
184
|
+
let bytes = 0;
|
|
185
|
+
for (const [i, it] of list.entries()) {
|
|
186
|
+
const p = path.resolve(it.path);
|
|
187
|
+
touched.add(p);
|
|
188
|
+
const before = sig(p);
|
|
189
|
+
const e = { path: p, role: it.role, before };
|
|
190
|
+
if (it.role === 'from') { entries.push(e); continue; } // a move is undone by moving back
|
|
191
|
+
if (before.kind === 'file') {
|
|
192
|
+
if (before.size > l.maxFileMb * MB) e.noCopy = 'big';
|
|
193
|
+
else { try { await fs.promises.copyFile(p, path.join(base, String(i))); e.blob = String(i); bytes += before.size; } catch (err) { e.noCopy = 'error'; log(`[bridgerator] snapshot copy failed: ${err.code || err.message}`); } }
|
|
194
|
+
} else if (before.kind === 'dir' && it.role === 'delete') {
|
|
195
|
+
const n = treeSize(p, l.maxFileMb * MB);
|
|
196
|
+
if (n === null) e.noCopy = 'big';
|
|
197
|
+
else { try { copyTree(p, path.join(base, String(i))); e.blob = String(i); bytes += n; } catch (err) { e.noCopy = 'error'; log(`[bridgerator] snapshot copy failed: ${err.code || err.message}`); } }
|
|
198
|
+
}
|
|
199
|
+
entries.push(e);
|
|
200
|
+
}
|
|
201
|
+
const a = { id, t: now(), tool, label: label || '', ...(task ? { task } : {}), entries, bytes };
|
|
202
|
+
// Written now (marked pending), so a crash before commit() does not lose the copy.
|
|
203
|
+
// maxMs: how long the action itself may keep changing files (a delegated run: its time limit).
|
|
204
|
+
save({ ...a, pending: true, pid: process.pid, started: SELF_START, ...(Number.isFinite(maxMs) && maxMs > 0 ? { maxMs } : {}) });
|
|
205
|
+
return a;
|
|
206
|
+
},
|
|
207
|
+
/**
|
|
208
|
+
* After the call: ok → kept with the state the AI left; failed → thrown away.
|
|
209
|
+
* added: paths created during the action (undo removes them); changedOnly: entries the action did
|
|
210
|
+
* not change are dropped with their copies (a whole folder captured before a delegated agent run).
|
|
211
|
+
*/
|
|
212
|
+
commit(a, ok, { added = [], changedOnly = false } = {}) {
|
|
213
|
+
if (!a) return null;
|
|
214
|
+
if (!ok) { drop(a); return null; }
|
|
215
|
+
for (const p of added) { const r = path.resolve(p); if (!a.entries.some((e) => e.path === r)) a.entries.push({ path: r, before: { kind: 'none' } }); }
|
|
216
|
+
for (const e of a.entries) e.after = sig(e.path);
|
|
217
|
+
if (changedOnly) {
|
|
218
|
+
for (const e of a.entries) {
|
|
219
|
+
if (!same(e.before, e.after) || !e.blob) continue;
|
|
220
|
+
try { fs.rmSync(path.join(dir, a.id, e.blob), { force: true }); a.bytes -= e.before.size || 0; } catch (err) { log(`[bridgerator] snapshot trim: ${err.code || err.message}`); }
|
|
221
|
+
}
|
|
222
|
+
a.entries = a.entries.filter((e) => !same(e.before, e.after));
|
|
223
|
+
}
|
|
224
|
+
// Nothing changed on disk (e.g. the engine refused) → nothing to undo.
|
|
225
|
+
if (a.entries.every((e) => same(e.before, e.after))) { drop(a); return null; }
|
|
226
|
+
actions.push(a); save(a); prune();
|
|
227
|
+
return a;
|
|
228
|
+
},
|
|
229
|
+
list() {
|
|
230
|
+
return [...actions].reverse().map((a) => ({
|
|
231
|
+
id: a.id, t: a.t, tool: a.tool, label: a.label, task: a.task || null, undone: !!a.undone,
|
|
232
|
+
paths: a.entries.map((e) => e.path),
|
|
233
|
+
noUndo: a.entries.some((e) => e.noCopy && e.before.kind !== 'none'),
|
|
234
|
+
}));
|
|
235
|
+
},
|
|
236
|
+
get bytes() { return actions.reduce((n, a) => n + (a.bytes || 0), 0); },
|
|
237
|
+
get count() { return actions.length; },
|
|
238
|
+
find: (id) => actions.find((a) => a.id === id) || null,
|
|
239
|
+
/** Undo one action → { id, restored: [path], skipped: [{ path, why }] }. check(path) → refusal text or null. */
|
|
240
|
+
undo(id, { check = () => null } = {}) {
|
|
241
|
+
const a = actions.find((x) => x.id === id);
|
|
242
|
+
if (!a) return { id, restored: [], skipped: [{ path: '', why: 'no such change (too old, or snapshots were cleared)' }] };
|
|
243
|
+
if (a.undone) return { id, restored: [], skipped: [{ path: '', why: 'already undone' }] };
|
|
244
|
+
const restored = [], skipped = [];
|
|
245
|
+
const base = path.join(dir, a.id);
|
|
246
|
+
const from = a.entries.find((e) => e.role === 'from'), to = a.entries.find((e) => e.role === 'to');
|
|
247
|
+
for (const e of a.entries) { const why = check(e.path); if (why) { skipped.push({ path: e.path, why }); } }
|
|
248
|
+
if (skipped.length) return { id, restored, skipped };
|
|
249
|
+
// Someone (the person, another chat) changed it after the AI → leave it, say so.
|
|
250
|
+
for (const e of a.entries) if (!same(sig(e.path), e.after)) skipped.push({ path: e.path, why: 'changed after ChatGPT\'s action — left as it is' });
|
|
251
|
+
if (skipped.length) return { id, restored, skipped };
|
|
252
|
+
try {
|
|
253
|
+
if (from && to) {
|
|
254
|
+
fs.mkdirSync(path.dirname(from.path), { recursive: true });
|
|
255
|
+
fs.renameSync(to.path, from.path); restored.push(from.path);
|
|
256
|
+
if (to.blob) { fs.copyFileSync(path.join(base, to.blob), to.path); restored.push(to.path); }
|
|
257
|
+
else if (to.noCopy && to.before.kind === 'file') skipped.push({ path: to.path, why: 'the file it replaced was too big to keep a copy' });
|
|
258
|
+
} else {
|
|
259
|
+
for (const e of a.entries) {
|
|
260
|
+
if (e.before.kind === 'none') { if (sig(e.path).kind === 'file') { fs.rmSync(e.path, { force: true }); restored.push(e.path); } continue; }
|
|
261
|
+
if (!e.blob) { skipped.push({ path: e.path, why: e.noCopy === 'big' ? `no copy (bigger than ${lim().maxFileMb} MB)${a.tool === 'delete' ? ' — it is in the Recycle Bin' : ''}` : 'no copy was kept' }); continue; }
|
|
262
|
+
fs.mkdirSync(path.dirname(e.path), { recursive: true });
|
|
263
|
+
if (e.before.kind === 'dir') copyTree(path.join(base, e.blob), e.path);
|
|
264
|
+
else { fs.copyFileSync(path.join(base, e.blob), e.path); try { const t = new Date(e.before.mtimeMs); fs.utimesSync(e.path, t, t); } catch { /* time only */ } }
|
|
265
|
+
restored.push(e.path);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
skipped.push({ path: '', why: `could not restore: ${err.code || err.message}` });
|
|
270
|
+
}
|
|
271
|
+
if (restored.length) { a.undone = now(); save(a); }
|
|
272
|
+
return { id, restored, skipped };
|
|
273
|
+
},
|
|
274
|
+
/** Several actions, newest first (later changes are undone before earlier ones). */
|
|
275
|
+
undoMany(ids, opts) {
|
|
276
|
+
const order = actions.filter((a) => ids.includes(a.id)).sort((x, y) => y.t - x.t).map((a) => a.id);
|
|
277
|
+
return order.map((id) => this.undo(id, opts));
|
|
278
|
+
},
|
|
279
|
+
clear() { for (const a of actions) drop(a); actions = []; },
|
|
280
|
+
prune,
|
|
281
|
+
/** Resolves when the pending metas of live pids have been checked (tests; startup never waits). */
|
|
282
|
+
ready,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Groups for "Undo AI changes": per queue task, and per batch (actions less than `gapMs` apart).
|
|
288
|
+
* → [{ kind: 'task'|'batch', key, title, t0, t1, ids }] newest first, only not-yet-undone actions.
|
|
289
|
+
*/
|
|
290
|
+
export function undoGroups(list, gapMs = 5 * 60000) {
|
|
291
|
+
const live = list.filter((a) => !a.undone).sort((x, y) => x.t - y.t);
|
|
292
|
+
const tasks = new Map(), batches = [];
|
|
293
|
+
for (const a of live) {
|
|
294
|
+
if (a.task) { const g = tasks.get(a.task.id) || { kind: 'task', key: a.task.id, title: a.task.title || a.task.id, t0: a.t, t1: a.t, ids: [] }; g.t1 = a.t; g.ids.push(a.id); tasks.set(a.task.id, g); }
|
|
295
|
+
const b = batches[batches.length - 1];
|
|
296
|
+
if (b && a.t - b.t1 < gapMs) { b.t1 = a.t; b.ids.push(a.id); } else batches.push({ kind: 'batch', key: a.id, title: '', t0: a.t, t1: a.t, ids: [a.id] });
|
|
297
|
+
}
|
|
298
|
+
return [...tasks.values(), ...batches].sort((x, y) => y.t1 - x.t1);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const UNDO_TOOL_DEF = {
|
|
302
|
+
name: 'bridgerator_undo',
|
|
303
|
+
description: 'Undoes ChatGPT\'s file changes on this PC (writes, edits, moves, deletions through the Recycle Bin) from Bridgerator\'s snapshots: the last change by default, a number of recent ones, one by id, or all changes made for a queue task. A file someone changed afterwards is left as it is and reported. action "list" shows recent changes that can be undone.',
|
|
304
|
+
inputSchema: {
|
|
305
|
+
type: 'object', additionalProperties: false,
|
|
306
|
+
properties: {
|
|
307
|
+
action: { type: 'string', enum: ['undo', 'list'] },
|
|
308
|
+
id: { type: 'string', maxLength: 40, description: 'Change id (from a result or the list).' },
|
|
309
|
+
task: { type: 'string', maxLength: 40, description: 'Queue item id: undo every change made while it was in progress.' },
|
|
310
|
+
count: { type: 'integer', minimum: 1, maximum: 50, description: 'Undo this many of the most recent changes (default 1).' },
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
};
|
package/src/win.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Absolute paths to the Windows tools we start: a planted powershell.exe / cmd.exe in the current
|
|
2
|
+
// folder or early on the PATH must never run instead.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
const SYS = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32');
|
|
7
|
+
export const POWERSHELL = path.join(SYS, 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
8
|
+
export const SCHTASKS = path.join(SYS, 'schtasks.exe');
|
|
9
|
+
export const CMD = path.join(SYS, 'cmd.exe');
|
|
10
|
+
// winget = App Installer's alias for this user (never looked up on the PATH).
|
|
11
|
+
export const WINGET = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Microsoft', 'WindowsApps', 'winget.exe');
|
|
12
|
+
export const WSCRIPT = path.join(SYS, 'wscript.exe');
|
|
13
|
+
export const WHOAMI = path.join(SYS, 'whoami.exe');
|
|
14
|
+
export const REG = path.join(SYS, 'reg.exe');
|
|
15
|
+
export const TASKKILL = path.join(SYS, 'taskkill.exe');
|
|
16
|
+
// -EncodedCommand: the script text never goes through command-line quoting.
|
|
17
|
+
export const psArgs = (script) => ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|