copilot-operator 0.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/LICENSE +21 -0
- package/README.md +556 -0
- package/dist/src/api/app.module.js +18 -0
- package/dist/src/api/dataAcl.js +109 -0
- package/dist/src/api/folderPicker.js +110 -0
- package/dist/src/api/main.js +132 -0
- package/dist/src/api/operator.controller.js +975 -0
- package/dist/src/api/operator.service.js +1971 -0
- package/dist/src/api/saveToDesktop.js +157 -0
- package/dist/src/api/security.js +225 -0
- package/dist/src/api/settings.js +57 -0
- package/dist/src/cli.js +531 -0
- package/dist/src/config/layout.js +66 -0
- package/dist/src/config/lockedPolicy.js +176 -0
- package/dist/src/config/schema.js +487 -0
- package/dist/src/context/contextFiles.js +295 -0
- package/dist/src/context/desktopMirror.js +113 -0
- package/dist/src/context/projectMirror.js +364 -0
- package/dist/src/exec/authorizer.js +61 -0
- package/dist/src/exec/checks.js +285 -0
- package/dist/src/exec/confinement.js +224 -0
- package/dist/src/exec/dangerous.js +161 -0
- package/dist/src/exec/environment.js +80 -0
- package/dist/src/exec/isolation.js +166 -0
- package/dist/src/exec/network.js +242 -0
- package/dist/src/exec/policy.js +201 -0
- package/dist/src/exec/policyManifest.js +84 -0
- package/dist/src/exec/processes.js +329 -0
- package/dist/src/exec/programs.js +290 -0
- package/dist/src/exec/redaction.js +93 -0
- package/dist/src/exec/reportFile.js +154 -0
- package/dist/src/exec/runner.js +245 -0
- package/dist/src/exec/scriptFiles.js +81 -0
- package/dist/src/exec/shellExecuteTrap.js +221 -0
- package/dist/src/exec/shells.js +298 -0
- package/dist/src/exec/stepEnv.js +72 -0
- package/dist/src/exec/winps.js +17 -0
- package/dist/src/exec/workDir.js +94 -0
- package/dist/src/log/runLog.js +43 -0
- package/dist/src/orchestrator/derivedChecks.js +95 -0
- package/dist/src/orchestrator/review.js +590 -0
- package/dist/src/orchestrator/taskRunner.js +1582 -0
- package/dist/src/plan/brief.js +1785 -0
- package/dist/src/plan/importPlan.js +202 -0
- package/dist/src/plan/schema.js +483 -0
- package/dist/src/plan/script.js +1012 -0
- package/dist/src/plan/systemGuide.js +1851 -0
- package/dist/src/protocol/parser.js +224 -0
- package/dist/src/protocol/replySchema.js +207 -0
- package/dist/src/protocol/reporter.js +75 -0
- package/dist/src/protocol/reviewSchema.js +205 -0
- package/dist/src/session/compose.js +67 -0
- package/dist/src/session/debugExport.js +165 -0
- package/dist/src/session/events.js +34 -0
- package/dist/src/session/exportRecord.js +261 -0
- package/dist/src/session/exports.js +484 -0
- package/dist/src/session/model.js +22 -0
- package/dist/src/session/retention.js +34 -0
- package/dist/src/session/runName.js +36 -0
- package/dist/src/session/store.js +440 -0
- package/dist/src/session/story.js +198 -0
- package/dist/src/transport/acceptance.js +34 -0
- package/dist/src/transport/chatSession.js +73 -0
- package/dist/src/transport/copilotTransport.js +1208 -0
- package/dist/src/transport/edgeCrash.js +59 -0
- package/dist/src/transport/locators.js +261 -0
- package/dist/src/transport/profileLock.js +102 -0
- package/dist/src/util/pacing.js +89 -0
- package/dist/src/vcs/commitHygiene.js +102 -0
- package/dist/src/vcs/git.js +286 -0
- package/dist/src/vcs/taskVcs.js +343 -0
- package/dist/web/404.html +15 -0
- package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
- package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
- package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
- package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
- package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
- package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
- package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
- package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
- package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
- package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
- package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
- package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
- package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
- package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
- package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
- package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
- package/dist/web/appearance.html +15 -0
- package/dist/web/appearance.txt +26 -0
- package/dist/web/defaults.html +15 -0
- package/dist/web/defaults.txt +26 -0
- package/dist/web/history.html +15 -0
- package/dist/web/history.txt +26 -0
- package/dist/web/import.html +15 -0
- package/dist/web/import.txt +26 -0
- package/dist/web/index.html +15 -0
- package/dist/web/index.txt +26 -0
- package/dist/web/level1.html +15 -0
- package/dist/web/level1.txt +26 -0
- package/dist/web/presets.html +18 -0
- package/dist/web/presets.txt +26 -0
- package/dist/web/sessions/view.html +15 -0
- package/dist/web/sessions/view.txt +26 -0
- package/dist/web/system.html +15 -0
- package/dist/web/system.txt +26 -0
- package/package.json +111 -0
- package/prompts/level1.md +535 -0
- package/prompts/organisation.bg.md +44 -0
- package/prompts/organisation.en.md +44 -0
- package/prompts/persona.bg.md +17 -0
- package/prompts/persona.en.md +17 -0
- package/prompts/review1.md +277 -0
- package/prompts/work.bg.md +19 -0
- package/prompts/work.en.md +19 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project files as chat context, via Context Picker.
|
|
3
|
+
*
|
|
4
|
+
* The user already maintains https://github.com/ivan-markov-666/context-picker, a VS Code
|
|
5
|
+
* and Visual Studio extension for ticking the files a project should hand to an LLM. It is
|
|
6
|
+
* not merely reusable here, it was clearly built with this exact case in mind. Its
|
|
7
|
+
* `copySelectionToDir` already carries:
|
|
8
|
+
*
|
|
9
|
+
* appendTxtExtension "so that tools which block source extensions (e.g. Microsoft 365
|
|
10
|
+
* Copilot) accept the uploads" - exactly the constraint we hit,
|
|
11
|
+
* since the chat's upload input rejects .ps1, .ts and friends
|
|
12
|
+
* pathInName flattens src/lib/app.ts into src--lib--app.ts, "lets a flat upload
|
|
13
|
+
* (e.g. OneDrive) keep the original folder structure in the name"
|
|
14
|
+
* syncOnly mirrors instead of wiping, "minimises churn for a synced folder
|
|
15
|
+
* such as OneDrive"
|
|
16
|
+
*
|
|
17
|
+
* So this module does not reimplement any of that. It shells out to the extension's own
|
|
18
|
+
* bridge CLI and then treats the export folder as the bot's context source.
|
|
19
|
+
*
|
|
20
|
+
* **Everything here happens in the file system, never through the browser.** The user points
|
|
21
|
+
* at the project root, ticks files in the extension, and the extension copies them into one
|
|
22
|
+
* folder on the Desktop. No Playwright, no upload form, no web picker in this path.
|
|
23
|
+
*
|
|
24
|
+
* **The folder is updated incrementally.** The extension's `syncOnly` mode writes only new
|
|
25
|
+
* and changed files and deletes the ones no longer selected, so a re-export does not rewrite
|
|
26
|
+
* a folder full of unchanged files. On top of that this module keeps a manifest of content
|
|
27
|
+
* hashes, so the bot can tell what actually changed since the last run and avoid handing the
|
|
28
|
+
* chat the same files again.
|
|
29
|
+
*
|
|
30
|
+
* If the Desktop happens to be backed up by OneDrive (Known Folder Move), the same folder
|
|
31
|
+
* also ends up in the cloud at no extra cost. That is a bonus, not the mechanism:
|
|
32
|
+
* `desktopIsSynced()` reports it, and nothing depends on it.
|
|
33
|
+
*
|
|
34
|
+
* Division of labour, which is the point of the request: **the human chooses the files** in
|
|
35
|
+
* the extension's checkbox UI, so they know exactly what the chat can see. The bot never
|
|
36
|
+
* decides what to expose.
|
|
37
|
+
*/
|
|
38
|
+
import { spawn } from 'node:child_process';
|
|
39
|
+
import { readdir, stat, readFile, writeFile } from 'node:fs/promises';
|
|
40
|
+
import { existsSync } from 'node:fs';
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
42
|
+
import { join, extname, resolve, sep } from 'node:path';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
/**
|
|
45
|
+
* Extensions the Copilot upload input accepts, restricted to what a code project produces.
|
|
46
|
+
* Taken from the live `accept` attribute of `#upload-file-button`.
|
|
47
|
+
*
|
|
48
|
+
* Note what is absent: .ts, .ps1, .sh, .go, .rb, .yml are not all there, which is precisely
|
|
49
|
+
* why Context Picker's `appendTxtExtension` exists. Exported files end in `.txt` and keep
|
|
50
|
+
* their original extension visible in the name, e.g. `src--app.ts.txt`.
|
|
51
|
+
* The flattening itself lives in `projectMirror.ts`; this module only consumes the folder.
|
|
52
|
+
*/
|
|
53
|
+
export const COPILOT_ACCEPTED = new Set([
|
|
54
|
+
'.txt', '.md', '.json', '.csv', '.tsv', '.xml', '.log', '.ini', '.config',
|
|
55
|
+
'.html', '.htm', '.css', '.js', '.jsx', '.py', '.java', '.cs', '.cpp', '.c', '.h',
|
|
56
|
+
'.sql', '.php', '.pl', '.rs', '.lua', '.dart', '.yaml', '.sh', '.bash', '.tsx',
|
|
57
|
+
'.pdf', '.docx', '.xlsx', '.pptx',
|
|
58
|
+
]);
|
|
59
|
+
export const DEFAULT_MAX_FILES = 20;
|
|
60
|
+
export const DEFAULT_MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
|
61
|
+
/**
|
|
62
|
+
* The local OneDrive root.
|
|
63
|
+
*
|
|
64
|
+
* Windows sets `OneDrive` for the signed-in account, plus `OneDriveCommercial` for a work
|
|
65
|
+
* or school account when both are present. Work first: that is the tenant the chat lives in.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveOneDriveRoot(env = process.env) {
|
|
68
|
+
const candidates = [env.OneDriveCommercial, env.OneDrive, env.OneDriveConsumer];
|
|
69
|
+
for (const c of candidates) {
|
|
70
|
+
if (c && c.trim())
|
|
71
|
+
return c;
|
|
72
|
+
}
|
|
73
|
+
const guess = join(homedir(), 'OneDrive');
|
|
74
|
+
return guess;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The Desktop folder.
|
|
78
|
+
*
|
|
79
|
+
* Candidates are tried in order and the first that exists wins, which is what makes this
|
|
80
|
+
* correct under OneDrive's Known Folder Move: when the Desktop is backed up, the real
|
|
81
|
+
* Desktop is `<OneDrive>\Desktop` and `%USERPROFILE%\Desktop` may not exist at all. No
|
|
82
|
+
* registry read is needed for that.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveDesktopDir(env = process.env) {
|
|
85
|
+
const candidates = [
|
|
86
|
+
env.OneDriveCommercial ? join(env.OneDriveCommercial, 'Desktop') : null,
|
|
87
|
+
env.OneDrive ? join(env.OneDrive, 'Desktop') : null,
|
|
88
|
+
join(env.USERPROFILE ?? homedir(), 'Desktop'),
|
|
89
|
+
].filter((c) => Boolean(c));
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
if (existsSync(c))
|
|
92
|
+
return c;
|
|
93
|
+
}
|
|
94
|
+
return candidates[candidates.length - 1];
|
|
95
|
+
}
|
|
96
|
+
/** Default export folder: one folder on the Desktop. */
|
|
97
|
+
export function defaultExportDir(env = process.env) {
|
|
98
|
+
return join(resolveDesktopDir(env), 'copilot-operator-context');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether the resolved Desktop is inside OneDrive, i.e. whether the export folder also
|
|
102
|
+
* reaches the cloud for free. Informational only: nothing in the bot depends on it.
|
|
103
|
+
*/
|
|
104
|
+
export function desktopIsSynced(env = process.env) {
|
|
105
|
+
return isInsideOneDrive(resolveDesktopDir(env), env);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a folder sits inside a local OneDrive root, i.e. whether saving into it will
|
|
109
|
+
* actually reach the cloud. This is the whole mechanism, so it is worth checking loudly:
|
|
110
|
+
* an export folder outside OneDrive silently produces local-only copies, and the mistake
|
|
111
|
+
* only shows up later as a chat that cannot see the project.
|
|
112
|
+
*/
|
|
113
|
+
export function isInsideOneDrive(dir, env = process.env) {
|
|
114
|
+
const roots = [env.OneDriveCommercial, env.OneDrive, env.OneDriveConsumer].filter((r) => Boolean(r && r.trim()));
|
|
115
|
+
const target = resolve(dir).toLowerCase();
|
|
116
|
+
return roots.some((r) => {
|
|
117
|
+
const root = resolve(r).toLowerCase();
|
|
118
|
+
return target === root || target.startsWith(root + sep);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Lists what is currently in the export folder, newest-agnostic, sorted by name. */
|
|
122
|
+
export async function listContextFiles(exportDir) {
|
|
123
|
+
let names;
|
|
124
|
+
try {
|
|
125
|
+
names = await readdir(exportDir);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const name of names.sort()) {
|
|
132
|
+
const path = join(exportDir, name);
|
|
133
|
+
const s = await stat(path).catch(() => null);
|
|
134
|
+
if (!s?.isFile())
|
|
135
|
+
continue;
|
|
136
|
+
out.push({
|
|
137
|
+
path,
|
|
138
|
+
name,
|
|
139
|
+
bytes: s.size,
|
|
140
|
+
accepted: COPILOT_ACCEPTED.has(extname(name).toLowerCase()),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Validates an export folder before anything is attached. Refusing early is much better
|
|
147
|
+
* than a half-uploaded selection, because a partial context makes Copilot answer
|
|
148
|
+
* confidently about files it never saw.
|
|
149
|
+
*/
|
|
150
|
+
export async function checkSelection(exportDir, cfg = {}) {
|
|
151
|
+
const maxFiles = cfg.maxFiles ?? DEFAULT_MAX_FILES;
|
|
152
|
+
const maxTotalBytes = cfg.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
|
153
|
+
const files = await listContextFiles(exportDir);
|
|
154
|
+
const totalBytes = files.reduce((n, f) => n + f.bytes, 0);
|
|
155
|
+
const problems = [];
|
|
156
|
+
if (files.length === 0) {
|
|
157
|
+
problems.push(`No files in ${exportDir}. Export a selection from Context Picker first.`);
|
|
158
|
+
}
|
|
159
|
+
if (files.length > maxFiles) {
|
|
160
|
+
problems.push(`${files.length} files exceeds maxFiles=${maxFiles}.`);
|
|
161
|
+
}
|
|
162
|
+
if (totalBytes > maxTotalBytes) {
|
|
163
|
+
problems.push(`${(totalBytes / 1048576).toFixed(1)} MB exceeds maxTotalBytes.`);
|
|
164
|
+
}
|
|
165
|
+
const rejected = files.filter((f) => !f.accepted);
|
|
166
|
+
if (rejected.length > 0) {
|
|
167
|
+
problems.push(`The chat upload rejects ${rejected.length} file(s): ${rejected.map((f) => f.name).join(', ')}. ` +
|
|
168
|
+
`Re-export with Context Picker's "append .txt" option.`);
|
|
169
|
+
}
|
|
170
|
+
const env = files.filter((f) => /(^|__|\.)env(\.|$)/i.test(f.name));
|
|
171
|
+
if (env.length > 0) {
|
|
172
|
+
problems.push(`Refusing to attach what look like env files: ${env.map((f) => f.name).join(', ')}.`);
|
|
173
|
+
}
|
|
174
|
+
const notes = [];
|
|
175
|
+
if (!isInsideOneDrive(exportDir)) {
|
|
176
|
+
// Not a problem: the Desktop is the target, and the bot reads it locally. It only means
|
|
177
|
+
// the folder is not additionally backed up to the cloud.
|
|
178
|
+
notes.push(`${exportDir} is not inside OneDrive, so these copies stay on this machine only. ` +
|
|
179
|
+
`Turn on OneDrive's Desktop backup, or point exportDir under ` +
|
|
180
|
+
`${resolveOneDriveRoot() ?? 'your OneDrive root'}, if you also want them in the cloud.`);
|
|
181
|
+
}
|
|
182
|
+
return { ok: problems.length === 0, files, totalBytes, problems, notes };
|
|
183
|
+
}
|
|
184
|
+
export const MANIFEST_NAME = '.cop-manifest.json';
|
|
185
|
+
async function sha256OfFile(path) {
|
|
186
|
+
const buf = await readFile(path);
|
|
187
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
188
|
+
}
|
|
189
|
+
export async function buildManifest(exportDir) {
|
|
190
|
+
const files = {};
|
|
191
|
+
for (const f of await listContextFiles(exportDir)) {
|
|
192
|
+
if (f.name === MANIFEST_NAME)
|
|
193
|
+
continue;
|
|
194
|
+
files[f.name] = { sha256: await sha256OfFile(f.path), bytes: f.bytes };
|
|
195
|
+
}
|
|
196
|
+
return { updatedAt: new Date().toISOString(), exportDir: resolve(exportDir), files };
|
|
197
|
+
}
|
|
198
|
+
export async function readManifest(manifestPath) {
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export async function writeManifest(manifestPath, manifest) {
|
|
207
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
208
|
+
}
|
|
209
|
+
/** Compares the folder as it is now against the manifest from the previous run. */
|
|
210
|
+
export function diffAgainstManifest(current, previous) {
|
|
211
|
+
const prev = previous?.files ?? {};
|
|
212
|
+
const added = [];
|
|
213
|
+
const changed = [];
|
|
214
|
+
const unchanged = [];
|
|
215
|
+
for (const [name, info] of Object.entries(current.files)) {
|
|
216
|
+
const before = prev[name];
|
|
217
|
+
if (!before)
|
|
218
|
+
added.push(name);
|
|
219
|
+
else if (before.sha256 !== info.sha256)
|
|
220
|
+
changed.push(name);
|
|
221
|
+
else
|
|
222
|
+
unchanged.push(name);
|
|
223
|
+
}
|
|
224
|
+
const removed = Object.keys(prev).filter((name) => !(name in current.files));
|
|
225
|
+
return {
|
|
226
|
+
added: added.sort(),
|
|
227
|
+
changed: changed.sort(),
|
|
228
|
+
removed: removed.sort(),
|
|
229
|
+
unchanged: unchanged.sort(),
|
|
230
|
+
toAttach: [...added, ...changed].sort(),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** One line for the log and for the covering message to Copilot. */
|
|
234
|
+
export function describeDiff(diff) {
|
|
235
|
+
if (diff.toAttach.length === 0 && diff.removed.length === 0) {
|
|
236
|
+
return 'Project context unchanged since the last run.';
|
|
237
|
+
}
|
|
238
|
+
const bits = [];
|
|
239
|
+
if (diff.added.length)
|
|
240
|
+
bits.push(`${diff.added.length} new`);
|
|
241
|
+
if (diff.changed.length)
|
|
242
|
+
bits.push(`${diff.changed.length} changed`);
|
|
243
|
+
if (diff.removed.length)
|
|
244
|
+
bits.push(`${diff.removed.length} removed`);
|
|
245
|
+
if (diff.unchanged.length)
|
|
246
|
+
bits.push(`${diff.unchanged.length} unchanged`);
|
|
247
|
+
return `Project context: ${bits.join(', ')}.`;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Bridge mode: re-export a saved selection by invoking Context Picker's own CLI, so a run
|
|
251
|
+
* always starts from current file contents without the user re-ticking anything.
|
|
252
|
+
*
|
|
253
|
+
* Defaults are chosen for this use case and should not be changed lightly:
|
|
254
|
+
* appendTxt true the chat rejects source extensions
|
|
255
|
+
* pathInName true a flat folder otherwise loses which `index.ts` is which
|
|
256
|
+
* syncOnly true OneDrive should not re-upload files that did not change
|
|
257
|
+
* includeEnvFiles false never ship secrets to a cloud chat
|
|
258
|
+
*/
|
|
259
|
+
export async function exportSelection(cfg) {
|
|
260
|
+
if (!cfg.bridgePath)
|
|
261
|
+
throw new Error('contextPicker.bridgePath is not configured');
|
|
262
|
+
if (!cfg.rootDir)
|
|
263
|
+
throw new Error('contextPicker.rootDir is required when pathInName is used');
|
|
264
|
+
const request = {
|
|
265
|
+
mode: 'copyfiles',
|
|
266
|
+
rootDir: resolve(cfg.rootDir),
|
|
267
|
+
targetDir: resolve(cfg.exportDir),
|
|
268
|
+
includedFiles: (cfg.includedFiles ?? []).map((f) => resolve(f)),
|
|
269
|
+
appendTxt: true,
|
|
270
|
+
pathInName: true,
|
|
271
|
+
separator: '__',
|
|
272
|
+
syncOnly: true,
|
|
273
|
+
stripComments: cfg.stripComments ?? false,
|
|
274
|
+
removeBlankLines: cfg.removeBlankLines ?? false,
|
|
275
|
+
includeEnvFiles: cfg.includeEnvFiles ?? false,
|
|
276
|
+
};
|
|
277
|
+
return await new Promise((resolvePromise, reject) => {
|
|
278
|
+
const child = spawn(process.execPath, [cfg.bridgePath], {
|
|
279
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
280
|
+
windowsHide: true,
|
|
281
|
+
});
|
|
282
|
+
let out = '';
|
|
283
|
+
let err = '';
|
|
284
|
+
child.stdout.on('data', (c) => (out += c.toString('utf8')));
|
|
285
|
+
child.stderr.on('data', (c) => (err += c.toString('utf8')));
|
|
286
|
+
child.on('error', reject);
|
|
287
|
+
child.on('close', (code) => {
|
|
288
|
+
if (code !== 0)
|
|
289
|
+
return reject(new Error(`context-picker bridge exited ${code}: ${err.trim()}`));
|
|
290
|
+
const written = Number.parseInt(out.trim(), 10);
|
|
291
|
+
resolvePromise({ written: Number.isFinite(written) ? written : 0, raw: out.trim() });
|
|
292
|
+
});
|
|
293
|
+
child.stdin.end(JSON.stringify(request), 'utf8');
|
|
294
|
+
});
|
|
295
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The projects on the Desktop: one folder each under `copilot-operator-context`.
|
|
3
|
+
*
|
|
4
|
+
* There used to be one flat folder for whatever the running session was mirroring. That was
|
|
5
|
+
* right for one project and wrong for three: every session's sync deleted the previous
|
|
6
|
+
* project's files ("no longer part of the selection"), and once the copies reached the chat
|
|
7
|
+
* as attachments, `src--main.ts.txt` from the API and `src--main.ts.txt` from the web app
|
|
8
|
+
* were the same name. So each project now has its own folder, named after it, and every
|
|
9
|
+
* flattened file carries the project's name in front — a file is then identifiable on the
|
|
10
|
+
* Desktop, in OneDrive and in the chat.
|
|
11
|
+
*
|
|
12
|
+
* Which projects: the ones on the Settings page — the default and the named others — each
|
|
13
|
+
* with its own selection of directories. The switch for keeping them all on the Desktop is
|
|
14
|
+
* `project.mirrorToDesktop`; a session's own mirror (the files attached to its first message)
|
|
15
|
+
* uses the same folder for its project whether or not the switch is on.
|
|
16
|
+
*/
|
|
17
|
+
import { basename, join, resolve } from 'node:path';
|
|
18
|
+
import { readdir, rm, stat } from 'node:fs/promises';
|
|
19
|
+
import { mirrorProject } from './projectMirror.js';
|
|
20
|
+
import { defaultExportDir } from './contextFiles.js';
|
|
21
|
+
const EMPTY_SELECTION = { includeDirs: [], excludeDirs: [], respectGitignore: true, includeEnvFiles: false };
|
|
22
|
+
function sameFolder(a, b) {
|
|
23
|
+
const norm = (p) => resolve(p).replace(/[\\/]+$/, '').toLowerCase();
|
|
24
|
+
return a.trim() !== '' && b.trim() !== '' && norm(a) === norm(b);
|
|
25
|
+
}
|
|
26
|
+
/** A project's name as a folder: what the operator typed, made safe for a file system. */
|
|
27
|
+
export function safeProjectName(name) {
|
|
28
|
+
return name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim().replace(/\.+$/, '') || 'project';
|
|
29
|
+
}
|
|
30
|
+
/** The projects on the Settings page, the default first, each with its selection. */
|
|
31
|
+
export function knownProjects(cfg) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const root = (cfg.project?.rootDir ?? '').trim();
|
|
34
|
+
if (root)
|
|
35
|
+
out.push({ name: (cfg.project?.name ?? '').trim() || basename(root) || 'project', rootDir: root, isDefault: true, mirror: cfg.project?.mirror ?? EMPTY_SELECTION });
|
|
36
|
+
for (const o of cfg.project?.others ?? [])
|
|
37
|
+
out.push({ name: o.name, rootDir: o.rootDir, isDefault: false, mirror: o.mirror ?? EMPTY_SELECTION });
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** What to call the folder for a session's project: its Settings name, else the folder's own. */
|
|
41
|
+
export function projectNameFor(rootDir, cfg) {
|
|
42
|
+
const known = knownProjects(cfg).find((p) => sameFolder(p.rootDir, rootDir));
|
|
43
|
+
return safeProjectName(known?.name ?? basename(resolve(rootDir)) ?? 'project');
|
|
44
|
+
}
|
|
45
|
+
/** The Desktop folder that holds one subfolder per project. */
|
|
46
|
+
export function contextRoot(cfg) {
|
|
47
|
+
return cfg.resolved.mirrorTargetDir ?? defaultExportDir();
|
|
48
|
+
}
|
|
49
|
+
export function projectTargetDir(cfg, projectName) {
|
|
50
|
+
return join(contextRoot(cfg), safeProjectName(projectName));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Files left directly in the context root by the flat layout this replaced. They would
|
|
54
|
+
* otherwise sit in OneDrive as stale copies with no project in their name, which is exactly
|
|
55
|
+
* the confusion the folders exist to end. Only files, only ours by the look of them.
|
|
56
|
+
*/
|
|
57
|
+
export async function removeLegacyFlatMirror(cfg) {
|
|
58
|
+
const root = contextRoot(cfg);
|
|
59
|
+
const removed = [];
|
|
60
|
+
for (const entry of await readdir(root).catch(() => [])) {
|
|
61
|
+
const path = join(root, entry);
|
|
62
|
+
const info = await stat(path).catch(() => null);
|
|
63
|
+
if (!info?.isFile())
|
|
64
|
+
continue;
|
|
65
|
+
if (!/\.txt$/i.test(entry) && !entry.includes('--'))
|
|
66
|
+
continue;
|
|
67
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
68
|
+
removed.push(entry);
|
|
69
|
+
}
|
|
70
|
+
return removed;
|
|
71
|
+
}
|
|
72
|
+
/** Brings one project's Desktop folder in line with its selection. */
|
|
73
|
+
export async function mirrorKnownProject(cfg, project) {
|
|
74
|
+
const name = safeProjectName(project.name);
|
|
75
|
+
const targetDir = projectTargetDir(cfg, name);
|
|
76
|
+
const result = await mirrorProject({
|
|
77
|
+
rootDir: project.rootDir,
|
|
78
|
+
includeDirs: project.mirror.includeDirs.length ? project.mirror.includeDirs : ['.'],
|
|
79
|
+
excludeDirs: project.mirror.excludeDirs,
|
|
80
|
+
targetDir,
|
|
81
|
+
namePrefix: name,
|
|
82
|
+
separator: cfg.projectMirror.separator,
|
|
83
|
+
txtMode: cfg.projectMirror.txtMode,
|
|
84
|
+
respectGitignore: project.mirror.respectGitignore,
|
|
85
|
+
ignoreDirs: cfg.projectMirror.ignoreDirs,
|
|
86
|
+
includeEnvFiles: project.mirror.includeEnvFiles,
|
|
87
|
+
maxFileBytes: cfg.projectMirror.maxFileBytes,
|
|
88
|
+
});
|
|
89
|
+
return { name, rootDir: project.rootDir, targetDir, result };
|
|
90
|
+
}
|
|
91
|
+
/** Every known project, when the switch is on. Nothing when it is off. */
|
|
92
|
+
export async function mirrorAllProjects(cfg) {
|
|
93
|
+
if (!cfg.project?.mirrorToDesktop)
|
|
94
|
+
return [];
|
|
95
|
+
await removeLegacyFlatMirror(cfg);
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const p of knownProjects(cfg))
|
|
98
|
+
out.push(await mirrorKnownProject(cfg, p));
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Removes every known project's folder from the Desktop. The switch going off. */
|
|
102
|
+
export async function removeProjectMirrors(cfg) {
|
|
103
|
+
const removed = [];
|
|
104
|
+
for (const p of knownProjects(cfg)) {
|
|
105
|
+
const dir = projectTargetDir(cfg, p.name);
|
|
106
|
+
const info = await stat(dir).catch(() => null);
|
|
107
|
+
if (!info?.isDirectory())
|
|
108
|
+
continue;
|
|
109
|
+
await rm(dir, { recursive: true, force: true });
|
|
110
|
+
removed.push(dir);
|
|
111
|
+
}
|
|
112
|
+
return removed;
|
|
113
|
+
}
|