omnirush 0.3.3 → 0.4.2
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/assets/collect-once.ts +77 -39
- package/assets/extensions/omnirush/auth.js +56 -17
- package/assets/extensions/omnirush/collector-lib.ts +510 -201
- package/assets/extensions/omnirush/collector.ts +10 -2
- package/assets/extensions/omnirush/commands.ts +148 -23
- package/assets/extensions/omnirush/index.ts +4 -0
- package/assets/extensions/omnirush/mcp-client.bundle.js +19432 -0
- package/assets/extensions/omnirush/mcp-lib.ts +199 -0
- package/assets/extensions/omnirush/mcp.ts +379 -0
- package/assets/extensions/omnirush/retry.js +144 -0
- package/assets/extensions/omnirush/sota-lib.ts +64 -0
- package/assets/extensions/omnirush/sota.ts +159 -28
- package/assets/extensions/omnirush/status-lib.ts +85 -0
- package/assets/extensions/omnirush/usage.ts +77 -0
- package/package.json +7 -2
- package/scripts/build-mcp-bundle.js +47 -0
- package/scripts/patch-pi-branding.js +27 -0
- package/scripts/postinstall.js +25 -8
- package/src/bin.js +299 -46
- package/src/lib.js +13 -0
package/assets/collect-once.ts
CHANGED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
// sessions via the omnirush pi extension).
|
|
4
4
|
//
|
|
5
5
|
// Builds the standard collect envelope (schema_version 1, snapshot_type
|
|
6
|
-
// "start") for a project directory and POSTs
|
|
7
|
-
// {origin}/collect with the device access token. Requires the
|
|
8
|
-
// bun (zstd) or Node >= 22.15.
|
|
6
|
+
// "start") for a project directory and POSTs each zstd-compressed part
|
|
7
|
+
// to {origin}/collect with the device access token. Requires the
|
|
8
|
+
// bundled bun (zstd) or Node >= 22.15.
|
|
9
|
+
//
|
|
10
|
+
// Memory (issue #6): files are read and packed one part at a time and
|
|
11
|
+
// each part is discarded right after its upload — nothing workspace-
|
|
12
|
+
// sized is ever held in full. The pre-flight summary walks sizes only
|
|
13
|
+
// (lstat), never content. Deterministic order: the walker yields paths
|
|
14
|
+
// sorted, each path's entries (manifest + slices) grouped.
|
|
9
15
|
//
|
|
10
16
|
// Usage: omnirush collect [--dir PATH] [--yes] [--dry-run]
|
|
11
17
|
|
|
@@ -13,10 +19,10 @@ import { randomBytes } from "node:crypto";
|
|
|
13
19
|
import path from "node:path";
|
|
14
20
|
import readline from "node:readline";
|
|
15
21
|
|
|
16
|
-
import {
|
|
22
|
+
import { resolveOrigin, gatewayUrlForOrigin } from "./extensions/omnirush/auth";
|
|
17
23
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
iterateCollectFiles,
|
|
25
|
+
iterateEnvelopeParts,
|
|
20
26
|
MAX_PART_COMPRESSED_BYTES,
|
|
21
27
|
MAX_PART_COMPRESSED_HARD,
|
|
22
28
|
MAX_SESSION_BYTES,
|
|
@@ -50,49 +56,36 @@ async function confirm(question: string): Promise<boolean> {
|
|
|
50
56
|
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/** Cheap workspace summary: path count + on-disk bytes (no content). */
|
|
60
|
+
async function summarize(dir: string): Promise<{ files: number; bytes: number }> {
|
|
61
|
+
const { lstat } = await import("node:fs/promises");
|
|
62
|
+
const { listWorkspaceFiles } = await import("./extensions/omnirush/collector-lib");
|
|
63
|
+
const paths = await listWorkspaceFiles(dir);
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
for (const relativePath of paths) {
|
|
66
|
+
try {
|
|
67
|
+
bytes += (await lstat(path.join(dir, relativePath))).size;
|
|
68
|
+
} catch {
|
|
69
|
+
/* raced away — the streaming pass re-checks */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { files: paths.length, bytes };
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
async function main() {
|
|
54
76
|
const { dir, yes, dryRun } = parseArgs(process.argv.slice(2));
|
|
55
77
|
const sessionId = `manual-${randomBytes(6).toString("hex")}`;
|
|
56
78
|
const workspaceId = path.basename(dir) || "workspace";
|
|
57
79
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
segment: 1,
|
|
61
|
-
resumed: false,
|
|
62
|
-
});
|
|
63
|
-
const bytes = files.reduce((total, file) => total + Buffer.byteLength(file.content), 0);
|
|
64
|
-
if (files.length <= 1) {
|
|
80
|
+
const summary = await summarize(dir);
|
|
81
|
+
if (summary.files === 0) {
|
|
65
82
|
console.error("omnirush collect: nothing to upload (empty or fully filtered workspace)");
|
|
66
83
|
process.exit(1);
|
|
67
84
|
}
|
|
68
|
-
|
|
69
|
-
const state = { id: sessionId, segment: 1, resumed: false };
|
|
70
|
-
const parts = await buildEnvelopeParts(
|
|
71
|
-
state,
|
|
72
|
-
"start",
|
|
73
|
-
files,
|
|
74
|
-
MAX_PART_COMPRESSED_BYTES,
|
|
75
|
-
(message, attributes) => console.error(`omnirush collect: ${message} (${JSON.stringify(attributes ?? {})})`),
|
|
76
|
-
);
|
|
77
|
-
const plainBytes = parts.reduce((total, part) => total + part.payload.length, 0);
|
|
78
|
-
const compressedBytes = parts.reduce((total, part) => total + part.compressed.length, 0);
|
|
79
|
-
console.log(
|
|
80
|
-
`collect: ${files.length - 1} files (${bytes} bytes, denylist applied) as session ${sessionId}`,
|
|
81
|
-
);
|
|
82
85
|
console.log(
|
|
83
|
-
`
|
|
84
|
-
`(target <= ${MAX_PART_COMPRESSED_BYTES}, rail ${MAX_PART_COMPRESSED_HARD})`,
|
|
86
|
+
`collect: ${summary.files} files (${summary.bytes} bytes on disk, denylist applied) as session ${sessionId}`,
|
|
85
87
|
);
|
|
86
88
|
|
|
87
|
-
if (dryRun) {
|
|
88
|
-
console.log("dry run — no upload performed");
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (!yes && !(await confirm(`Upload ${files.length - 1} files from ${dir} to Omnirush? [y/N] `))) {
|
|
92
|
-
console.log("aborted");
|
|
93
|
-
process.exit(1);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
89
|
const origin = resolveOrigin(process.env);
|
|
97
90
|
const collectUrl = gatewayUrlForOrigin(origin).replace(/\/v1$/, "/collect");
|
|
98
91
|
const accessToken = (
|
|
@@ -104,7 +97,34 @@ async function main() {
|
|
|
104
97
|
console.error("omnirush collect: no access token (run `omnirush login`)");
|
|
105
98
|
process.exit(1);
|
|
106
99
|
}
|
|
107
|
-
|
|
100
|
+
|
|
101
|
+
const state = { id: sessionId, segment: 1, resumed: false };
|
|
102
|
+
// Pass 2 streams content: file -> batch -> part -> upload, with only
|
|
103
|
+
// one part ever resident. `part` is dropped at each loop iteration.
|
|
104
|
+
const parts = iterateEnvelopeParts(
|
|
105
|
+
state,
|
|
106
|
+
"start",
|
|
107
|
+
iterateCollectFiles(dir, MAX_SESSION_BYTES, workspaceId, {
|
|
108
|
+
id: sessionId,
|
|
109
|
+
segment: 1,
|
|
110
|
+
resumed: false,
|
|
111
|
+
}),
|
|
112
|
+
MAX_PART_COMPRESSED_BYTES,
|
|
113
|
+
(message, attributes) => console.error(`omnirush collect: ${message} (${JSON.stringify(attributes ?? {})})`),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
let partCount = 0;
|
|
117
|
+
let plainBytes = 0;
|
|
118
|
+
let compressedBytes = 0;
|
|
119
|
+
let uploaded = false;
|
|
120
|
+
for await (const part of parts) {
|
|
121
|
+
partCount += 1;
|
|
122
|
+
plainBytes += part.payload.length;
|
|
123
|
+
compressedBytes += part.compressed.length;
|
|
124
|
+
if (dryRun) {
|
|
125
|
+
console.log(`part ${partCount}: ${part.compressed.length} bytes zstd (discarded — dry run)`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
108
128
|
const response = await fetch(collectUrl, {
|
|
109
129
|
method: "POST",
|
|
110
130
|
headers: {
|
|
@@ -123,6 +143,7 @@ async function main() {
|
|
|
123
143
|
console.error(`upload failed: HTTP ${response.status}`);
|
|
124
144
|
process.exit(1);
|
|
125
145
|
}
|
|
146
|
+
uploaded = true;
|
|
126
147
|
const extra = [...response.headers]
|
|
127
148
|
.filter(([name]) => name.toLowerCase().startsWith("x-omnirush-"))
|
|
128
149
|
.map(([name, value]) => `${name}: ${value}`);
|
|
@@ -132,6 +153,23 @@ async function main() {
|
|
|
132
153
|
`${extra.length ? "\n " + extra.join("\n ") : ""}`,
|
|
133
154
|
);
|
|
134
155
|
}
|
|
156
|
+
|
|
157
|
+
if (partCount === 0) {
|
|
158
|
+
console.error("omnirush collect: nothing to upload (empty or fully filtered workspace)");
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
console.log(
|
|
162
|
+
`envelope: ${partCount} part(s), ${plainBytes} bytes plain, ${compressedBytes} bytes zstd ` +
|
|
163
|
+
`(target <= ${MAX_PART_COMPRESSED_BYTES}, rail ${MAX_PART_COMPRESSED_HARD})`,
|
|
164
|
+
);
|
|
165
|
+
if (dryRun) {
|
|
166
|
+
console.log("dry run — no upload performed");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!uploaded) {
|
|
170
|
+
console.error("omnirush collect: nothing uploaded");
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
135
173
|
}
|
|
136
174
|
|
|
137
175
|
main().catch((error) => {
|
|
@@ -29,6 +29,12 @@ import fs from "node:fs";
|
|
|
29
29
|
import os from "node:os";
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
|
|
32
|
+
import {
|
|
33
|
+
isRetryableStatus,
|
|
34
|
+
retryAttempts,
|
|
35
|
+
withRetries,
|
|
36
|
+
} from "./retry.js";
|
|
37
|
+
|
|
32
38
|
export const DEFAULT_ORIGIN = "https://omnirush.ai/omnirush";
|
|
33
39
|
export const DEFAULT_POLL_INTERVAL_SEC = 5;
|
|
34
40
|
export const MAX_POLL_INTERVAL_SEC = 30;
|
|
@@ -171,19 +177,36 @@ function detailOf(payload, fallback) {
|
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
async function postJson(url, body, fetchImpl, timeoutMs = 20_000) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
180
|
+
// Shared retry layer: 429/5xx/network failures ride out backend
|
|
181
|
+
// deploys. 4xx are protocol answers (428 pending, 401 rejected) —
|
|
182
|
+
// never retried here; callers interpret them.
|
|
183
|
+
const outcome = await withRetries(
|
|
184
|
+
async () => {
|
|
185
|
+
const response = await fetchImpl(url, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
190
|
+
});
|
|
191
|
+
let payload = null;
|
|
192
|
+
try {
|
|
193
|
+
payload = await response.json();
|
|
194
|
+
} catch {
|
|
195
|
+
/* non-JSON body — fall through with payload null */
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
response,
|
|
199
|
+
payload,
|
|
200
|
+
retryAfterSec: Number(response?.headers?.get?.("retry-after")) || undefined,
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
attempts: retryAttempts(),
|
|
205
|
+
isRetryable: ({ response }) => isRetryableStatus(response?.status),
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
if (outcome.error) throw outcome.error;
|
|
209
|
+
return outcome.result;
|
|
187
210
|
}
|
|
188
211
|
|
|
189
212
|
/** Step 1 of the device flow: create a pending authorization. */
|
|
@@ -305,10 +328,26 @@ export async function refreshTokens(origin, { refreshToken, fetchImpl } = {}) {
|
|
|
305
328
|
|
|
306
329
|
export async function deviceMe(origin, { accessToken, fetchImpl } = {}) {
|
|
307
330
|
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
331
|
+
// 401 is a protocol answer (stale access token — the caller refreshes
|
|
332
|
+
// and retries once); 429/5xx/network retry with backoff.
|
|
333
|
+
const outcome = await withRetries(
|
|
334
|
+
async () => {
|
|
335
|
+
const response = await doFetch(`${origin}/device/me`, {
|
|
336
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
337
|
+
signal: AbortSignal.timeout(15_000),
|
|
338
|
+
});
|
|
339
|
+
return {
|
|
340
|
+
response,
|
|
341
|
+
retryAfterSec: Number(response?.headers?.get?.("retry-after")) || undefined,
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
attempts: retryAttempts(),
|
|
346
|
+
isRetryable: ({ response }) => isRetryableStatus(response?.status),
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
if (outcome.error) throw outcome.error;
|
|
350
|
+
const response = outcome.result.response;
|
|
312
351
|
if (response.status === 401) return null;
|
|
313
352
|
if (!response.ok) throw new Error(`device me failed: HTTP ${response.status}`);
|
|
314
353
|
return response.json();
|