bridgerator 1.0.8 → 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 +76 -14
- 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 +455 -56
- 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 +720 -29
- package/src/cli.mjs +529 -74
- 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 +84 -23
- package/src/pair.mjs +27 -4
- 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 -124
- 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/batch.mjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Plan card"). Batch approval: when confirmations are on and
|
|
2
|
+
// the AI is about to do many similar actions (move 214 files…), it sends the list first
|
|
3
|
+
// (bridgerator_plan propose) → ONE card on the PC: Allow all / Review (the window: tick items, Allow
|
|
4
|
+
// selected) / Deny. Allowed items then pass the confirmation without their own question — only the
|
|
5
|
+
// exact listed tool + paths (or command text), each as many times as listed, for 30 minutes. The
|
|
6
|
+
// folder rules (policy.check) and everything else still apply to every call. Approvals live in
|
|
7
|
+
// memory only (a restart forgets them). dryRun: the same list is only checked (refused by the folder
|
|
8
|
+
// rules / would ask / runs freely, missing sources, existing targets); nothing is asked or run.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { findMatch, detail, clip, LABEL } from './confirm.mjs';
|
|
12
|
+
import { norm } from './policy.mjs';
|
|
13
|
+
import { fail, json, needAction, str } from './tool-lib.mjs';
|
|
14
|
+
|
|
15
|
+
export const PLAN_TTL_MS = 30 * 60000, REVIEW_MS = 10 * 60000, MAX_ITEMS = 1000, DENY_COOLDOWN_MS = 10 * 60000;
|
|
16
|
+
// The engine tools a confirmation can stop (findMatch): their key arguments identify the item.
|
|
17
|
+
const KEY = {
|
|
18
|
+
move_file: (a) => `move|${norm(a.source)}|${norm(a.destination)}`,
|
|
19
|
+
write_file: (a) => `write|${norm(a.path)}`,
|
|
20
|
+
edit_block: (a) => `edit|${norm(a.file_path)}`,
|
|
21
|
+
create_directory: (a) => `mkdir|${norm(a.path)}`,
|
|
22
|
+
start_process: (a) => `cmd|${String(a.command).trim()}`,
|
|
23
|
+
};
|
|
24
|
+
const NEED = { move_file: ['source', 'destination'], write_file: ['path'], edit_block: ['file_path'], create_directory: ['path'], start_process: ['command'] };
|
|
25
|
+
export const PLAN_TOOLS = Object.keys(KEY);
|
|
26
|
+
/** → the approval key of a call, or null when such a call can't be part of a plan. */
|
|
27
|
+
export function itemKey(tool, args) {
|
|
28
|
+
if (!KEY[tool] || !args || typeof args !== 'object') return null;
|
|
29
|
+
if (NEED[tool].some((k) => typeof args[k] !== 'string' || !args[k].trim())) return null;
|
|
30
|
+
try { return KEY[tool](args); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
// [verb, plural noun of the mixed list, plural object, singular noun of the mixed list]
|
|
33
|
+
const VERB = { move_file: ['move', 'moves', 'files', 'move'], write_file: ['write', 'writes', 'files', 'write'], edit_block: ['edit', 'edits', 'files', 'edit'], create_directory: ['create', 'new folders', 'folders', 'new folder'], start_process: ['run', 'commands', 'commands', 'command'] };
|
|
34
|
+
/** "ChatGPT wants to move 214 files" / "ChatGPT wants to do 220 actions (214 moves, 6 commands)". */
|
|
35
|
+
export function headline(rows) {
|
|
36
|
+
const by = {};
|
|
37
|
+
for (const r of rows) by[r.tool] = (by[r.tool] || 0) + 1;
|
|
38
|
+
const kinds = Object.keys(by);
|
|
39
|
+
if (kinds.length === 1) { const [k] = kinds, n = by[k]; return n === 1 ? `ChatGPT wants to ${VERB[k][0]} 1 ${VERB[k][2].replace(/s$/, '')}` : `ChatGPT wants to ${VERB[k][0]} ${n} ${VERB[k][2]}`; }
|
|
40
|
+
return `ChatGPT wants to do ${rows.length} actions (${kinds.map((k) => `${by[k]} ${VERB[k][by[k] === 1 ? 3 : 1]}`).join(', ')})`;
|
|
41
|
+
}
|
|
42
|
+
// Dry run notes: what the action would meet on disk (nothing is changed). Only rows the folder rules
|
|
43
|
+
// allow, only full paths on the system drive (never a network / device path or another drive letter,
|
|
44
|
+
// which may be a mapped network drive: a stat there can hang and hold one of libuv's few threads past
|
|
45
|
+
// the time limit), async with a short time limit each, a few at a time. Other paths: no note.
|
|
46
|
+
const LOCAL = /^[a-z]:[\\/]/i;
|
|
47
|
+
const SYS = String(process.env.SystemDrive || 'C:').slice(0, 2).toLowerCase();
|
|
48
|
+
const onSystemDrive = (p) => LOCAL.test(p) && p.slice(0, 2).toLowerCase() === SYS;
|
|
49
|
+
const STAT_MS = 2000, STAT_AT_ONCE = 8;
|
|
50
|
+
const exists = (p) => (typeof p !== 'string' || !onSystemDrive(p.trim()) ? Promise.resolve(null)
|
|
51
|
+
: Promise.race([fs.promises.stat(p.trim()).then(() => true, () => false), new Promise((r) => { const t = setTimeout(() => r(null), STAT_MS); t.unref?.(); })]));
|
|
52
|
+
async function dryNote(tool, a) {
|
|
53
|
+
const e = async (p) => { const x = await exists(p); if (x === null) throw new Error('unknown'); return x; };
|
|
54
|
+
try {
|
|
55
|
+
if (tool === 'move_file') return !(await e(a.source)) ? 'source missing' : (await e(a.destination)) ? 'target exists' : '';
|
|
56
|
+
if (tool === 'write_file') return (await e(a.path)) ? 'overwrites' : 'new file';
|
|
57
|
+
if (tool === 'edit_block') return (await e(a.file_path)) ? '' : 'file missing';
|
|
58
|
+
if (tool === 'create_directory') return (await e(a.path)) ? 'exists' : '';
|
|
59
|
+
} catch { return ''; }
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
async function dryNotes(rows) {
|
|
63
|
+
const out = new Map();
|
|
64
|
+
let next = 0;
|
|
65
|
+
const worker = async () => { while (next < rows.length) { const r = rows[next++]; if (!r.refused) out.set(r.i, await dryNote(r.tool, r.args)); } };
|
|
66
|
+
await Promise.all(Array.from({ length: Math.min(STAT_AT_ONCE, rows.length) }, worker));
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The card's one line: file names first (the full paths are in the window's list).
|
|
71
|
+
const base = (p) => String(p).replace(/[\\/]+$/, '').split(/[\\/]/).pop();
|
|
72
|
+
function brief(r) {
|
|
73
|
+
const a = r.args;
|
|
74
|
+
if (r.tool === 'move_file') return `${base(a.source)} → ${a.destination}`;
|
|
75
|
+
if (r.tool === 'start_process') return String(a.command).replace(/\s+/g, ' ').trim();
|
|
76
|
+
return a.path || a.file_path ? `${base(a.path || a.file_path)} (${a.path || a.file_path})` : r.text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const REPLACED = Symbol('replaced');
|
|
80
|
+
export function createBatch({ now = Date.now, openWindow = () => {}, log = () => {} } = {}) {
|
|
81
|
+
const plans = new Map(); // id → { id, title, keys: Map(key → uses left), expires, rows }
|
|
82
|
+
let pending = null; // the card waiting in the window: { id, title, head, rows, resolve }
|
|
83
|
+
let deniedUntil = 0;
|
|
84
|
+
const sweep = () => { for (const [id, p] of plans) if (p.expires <= now()) plans.delete(id); };
|
|
85
|
+
|
|
86
|
+
function read(ctx, args) {
|
|
87
|
+
const items = args.items;
|
|
88
|
+
if (!Array.isArray(items) || !items.length || items.length > MAX_ITEMS) fail('BAD_ARGS', `items must be a list of 1–${MAX_ITEMS} actions { tool, args }.`);
|
|
89
|
+
const s = ctx.gate.settings();
|
|
90
|
+
return items.map((it, i) => {
|
|
91
|
+
if (!it || typeof it !== 'object' || Array.isArray(it)) fail('BAD_ARGS', `items[${i}] must be { tool, args }.`);
|
|
92
|
+
const tool = it.tool, a = it.args;
|
|
93
|
+
if (!PLAN_TOOLS.includes(tool)) fail('BAD_ARGS', `items[${i}].tool must be one of ${PLAN_TOOLS.join(', ')}.`);
|
|
94
|
+
if (!a || typeof a !== 'object' || Array.isArray(a)) fail('BAD_ARGS', `items[${i}].args must be the tool's arguments.`);
|
|
95
|
+
const key = itemKey(tool, a);
|
|
96
|
+
if (!key) fail('BAD_ARGS', `items[${i}].args need ${NEED[tool].join(' and ')}.`);
|
|
97
|
+
const refused = ctx.policy.check(tool, a);
|
|
98
|
+
const m = refused ? { cat: null, fragment: '' } : findMatch(tool, a, s.ask, s.patterns);
|
|
99
|
+
return { i, tool, key, args: a, text: detail(tool, a, m.fragment, 160), refused: refused || null, cat: s.enabled ? m.cat : null };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The card: notification first (Allow all / Review / Deny); Review = the window with the list. → indexes allowed. */
|
|
104
|
+
async function card(ctx, id, title, rows) {
|
|
105
|
+
const head = headline(rows);
|
|
106
|
+
const first = brief(rows[0]), last = brief(rows[rows.length - 1]);
|
|
107
|
+
const text = clip(rows.length === 1 ? first : `${title ? `${title}: ` : ''}${first} … ${last}`, '', 140);
|
|
108
|
+
const s = ctx.gate.settings();
|
|
109
|
+
const r = await ctx.gate.askPerson(`plan:${id}`, { head, text, buttons: [['allow', 'Allow all', ''], ['review', 'Review…', ''], ['deny', 'Deny', 'deny']], secs: s.timeoutSec, auto: 'auto-deny', ...(ctx.popup?.() || {}) });
|
|
110
|
+
if (r === 'allow') return rows.map((x) => x.i);
|
|
111
|
+
if (r !== 'review') return null;
|
|
112
|
+
openWindow('home');
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const timer = setTimeout(() => { if (pending?.id === id) { pending = null; resolve(null); } }, REVIEW_MS);
|
|
115
|
+
timer.unref?.();
|
|
116
|
+
// One card in the window at a time: a newer plan under review replaces an older one, which then
|
|
117
|
+
// ends as "replaced" (its propose call answers; no cool-down for the newer plan).
|
|
118
|
+
if (pending) pending.resolve(REPLACED);
|
|
119
|
+
const me = { id, title, head, rows, resolve: (sel) => { clearTimeout(timer); if (pending === me) pending = null; resolve(sel); } };
|
|
120
|
+
pending = me;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const api = {
|
|
125
|
+
/** bridge.mjs, before the confirmation: a call listed in an approved plan → true (one use taken). */
|
|
126
|
+
covers(name, args, gate) {
|
|
127
|
+
const key = itemKey(name, args);
|
|
128
|
+
if (!key || !plans.size) return false;
|
|
129
|
+
const s = gate.settings();
|
|
130
|
+
if (!s.enabled || !findMatch(name, args, s.ask, s.patterns).cat) return false; // would not ask anyway: keep the use
|
|
131
|
+
sweep();
|
|
132
|
+
for (const p of plans.values()) {
|
|
133
|
+
const left = p.keys.get(key);
|
|
134
|
+
if (left > 0) { if (left === 1) p.keys.delete(key); else p.keys.set(key, left - 1); p.used++; return true; }
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
},
|
|
138
|
+
/** The window's view of the card under review. */
|
|
139
|
+
pending: () => (pending ? { id: pending.id, title: pending.title, head: pending.head, rows: pending.rows.map((r) => ({ i: r.i, text: r.text, cat: r.cat })) } : null),
|
|
140
|
+
/** The person's answer in the window: allow = list of item indexes ([] = deny). */
|
|
141
|
+
decide(id, allow) {
|
|
142
|
+
if (!pending || pending.id !== id) return false;
|
|
143
|
+
const ok = new Set(pending.rows.map((r) => r.i));
|
|
144
|
+
pending.resolve(Array.isArray(allow) ? [...new Set(allow.filter((i) => Number.isInteger(i) && ok.has(i)))] : null);
|
|
145
|
+
return true;
|
|
146
|
+
},
|
|
147
|
+
active() { sweep(); return [...plans.values()].map((p) => ({ planId: p.id, title: p.title, left: [...p.keys.values()].reduce((a, b) => a + b, 0), used: p.used, expiresAt: new Date(p.expires).toISOString() })); },
|
|
148
|
+
async propose(ctx, args) {
|
|
149
|
+
const title = args.title === undefined ? '' : str(args, 'title', { max: 120 }).replace(/\s+/g, ' ').trim();
|
|
150
|
+
const rows = read(ctx, args);
|
|
151
|
+
const s = ctx.gate.settings();
|
|
152
|
+
const refused = rows.filter((r) => r.refused).map((r) => ({ index: r.i, reason: r.refused }));
|
|
153
|
+
const ask = rows.filter((r) => !r.refused && r.cat);
|
|
154
|
+
if (args.dryRun === true) {
|
|
155
|
+
const shown = rows.slice(0, 200), notes = await dryNotes(shown);
|
|
156
|
+
const cats = {};
|
|
157
|
+
for (const r of ask) cats[r.cat] = (cats[r.cat] || 0) + 1;
|
|
158
|
+
return json({
|
|
159
|
+
dryRun: true, total: rows.length, refused, confirmations: s.enabled ? 'on' : 'off',
|
|
160
|
+
wouldAsk: ask.length, ...(ask.length ? { askFor: Object.fromEntries(Object.entries(cats).map(([k, n]) => [LABEL[k] || k, n])) } : {}), runsFreely: rows.length - refused.length - ask.length,
|
|
161
|
+
items: shown.map((r) => ({ index: r.i, action: r.text, ...(r.refused ? { refused: true } : r.cat ? { asks: LABEL[r.cat] || r.cat } : {}), ...(notes.get(r.i) ? { note: notes.get(r.i) } : {}) })),
|
|
162
|
+
...(rows.length > 200 ? { more: rows.length - 200 } : {}),
|
|
163
|
+
note: 'Nothing was asked or run.',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (!s.enabled) return json({ needed: false, refused, note: 'Confirmations are off on this PC: these actions run without questions (the folder rules still apply).' });
|
|
167
|
+
if (!ask.length) return json({ needed: false, refused, note: 'None of these actions asks on this PC; no approval needed.' });
|
|
168
|
+
if (now() < deniedUntil) return { content: [{ type: 'text', text: 'Not approved — the person at the PC declined a plan a few minutes ago. Ask them in the chat first.' }], isError: true, notConfirmed: true };
|
|
169
|
+
const id = crypto.randomBytes(4).toString('hex');
|
|
170
|
+
const sel = await card(ctx, id, title, ask);
|
|
171
|
+
if (sel === REPLACED) return { content: [{ type: 'text', text: JSON.stringify({ planId: id, approved: false, replaced: true, refused, note: 'A newer plan card replaced this one in the Bridgerator window before the person answered. Each action would ask on its own; propose again if needed.' }, null, 2) }], isError: true, notConfirmed: true };
|
|
172
|
+
if (!sel || !sel.length) {
|
|
173
|
+
deniedUntil = now() + DENY_COOLDOWN_MS;
|
|
174
|
+
return { content: [{ type: 'text', text: JSON.stringify({ planId: id, approved: false, refused, note: 'The person at the PC did not allow this plan (denied or no answer). Each action would ask on its own.' }, null, 2) }], isError: true, notConfirmed: true };
|
|
175
|
+
}
|
|
176
|
+
const keys = new Map(), allowed = new Set(sel);
|
|
177
|
+
for (const r of ask) if (allowed.has(r.i)) keys.set(r.key, (keys.get(r.key) || 0) + 1);
|
|
178
|
+
const p = { id, title, keys, used: 0, expires: now() + PLAN_TTL_MS };
|
|
179
|
+
plans.set(id, p);
|
|
180
|
+
log(`[bridgerator] plan ${id} approved on the PC: ${allowed.size} of ${ask.length} actions`);
|
|
181
|
+
return json({ planId: id, approved: true, allowed: allowed.size, notAllowed: ask.length - allowed.size, notAllowedIndexes: ask.filter((r) => !allowed.has(r.i)).map((r) => r.i).slice(0, 200), refused, expiresAt: new Date(p.expires).toISOString(), note: 'Allowed actions run without their own question until expiresAt, each as many times as listed. Actions not allowed still ask.' });
|
|
182
|
+
},
|
|
183
|
+
status(planId) { sweep(); const p = plans.get(planId); return p ? { planId, active: true, left: [...p.keys.values()].reduce((a, b) => a + b, 0), used: p.used, expiresAt: new Date(p.expires).toISOString() } : { planId, active: false }; },
|
|
184
|
+
cancel(planId) { return plans.delete(planId); },
|
|
185
|
+
};
|
|
186
|
+
return api;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- the tool -------------------------------------------------------------------------------------------
|
|
190
|
+
const ACTIONS = ['propose', 'status', 'cancel'];
|
|
191
|
+
export const PLAN_TOOL = {
|
|
192
|
+
name: 'bridgerator_plan',
|
|
193
|
+
title: 'Plan card (one approval for many actions)',
|
|
194
|
+
description: 'One approval on the PC for a list of similar actions (e.g. moving 200 files) while confirmations are on, instead of a question per action. propose: items [{ tool, args }] with the exact arguments planned; the person allows all, some or none, and allowed items then run without their own question for 30 minutes. dryRun true only checks the list (refused by folder rules, would ask, missing files) — nothing is asked or run. status / cancel by planId.',
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: 'object', additionalProperties: false, required: ['action'],
|
|
197
|
+
properties: {
|
|
198
|
+
action: { type: 'string', enum: ACTIONS },
|
|
199
|
+
title: { type: 'string', maxLength: 120 },
|
|
200
|
+
items: { type: 'array', minItems: 1, maxItems: MAX_ITEMS, items: { type: 'object', additionalProperties: false, required: ['tool', 'args'], properties: { tool: { type: 'string', enum: PLAN_TOOLS }, args: { type: 'object' } } } },
|
|
201
|
+
dryRun: { type: 'boolean' },
|
|
202
|
+
planId: { type: 'string', maxLength: 8 },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
206
|
+
};
|
|
207
|
+
export const defs = [PLAN_TOOL];
|
|
208
|
+
|
|
209
|
+
const batches = new WeakMap();
|
|
210
|
+
export const attachBatch = (cfg, b) => { batches.set(cfg, b); return b; };
|
|
211
|
+
export const batchFor = (cfg) => batches.get(cfg) || null;
|
|
212
|
+
|
|
213
|
+
export async function handle(name, args, ctx) {
|
|
214
|
+
const b = batchFor(ctx.cfg);
|
|
215
|
+
if (!b) fail('NOT_AVAILABLE', 'Plan cards are not available on this PC.');
|
|
216
|
+
const action = needAction(args, ACTIONS);
|
|
217
|
+
if (action === 'propose') return b.propose(ctx, args);
|
|
218
|
+
const id = str(args, 'planId', { required: true, max: 8 });
|
|
219
|
+
if (action === 'status') return json(b.status(id));
|
|
220
|
+
return json({ planId: id, cancelled: b.cancel(id) });
|
|
221
|
+
}
|