march-cli 0.1.35 → 0.1.36
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/package.json +1 -1
- package/src/agent/code-search/cache.mjs +133 -0
- package/src/agent/code-search/chunk-rules.mjs +107 -0
- package/src/agent/code-search/chunker.mjs +125 -0
- package/src/agent/code-search/engine.mjs +109 -0
- package/src/agent/code-search/languages.mjs +25 -0
- package/src/agent/code-search/parser-pool.mjs +29 -0
- package/src/agent/code-search/rerank.mjs +43 -0
- package/src/agent/code-search/retrieval/bm25.mjs +47 -0
- package/src/agent/code-search/retrieval/fusion.mjs +18 -0
- package/src/agent/code-search/retrieval/model2vec.mjs +96 -0
- package/src/agent/code-search/retrieval/safetensors.mjs +49 -0
- package/src/agent/code-search/retrieval/vector.mjs +107 -0
- package/src/agent/code-search/retrieval/wordpiece.mjs +82 -0
- package/src/agent/code-search/scanner.mjs +84 -0
- package/src/agent/code-search/tokenize.mjs +16 -0
- package/src/agent/code-search/tool.mjs +75 -0
- package/src/agent/runner/provider-quota-runtime.mjs +38 -0
- package/src/agent/runner.mjs +5 -0
- package/src/agent/runtime/remote-runner-client.mjs +2 -0
- package/src/agent/runtime/runner-ipc-target.mjs +7 -0
- package/src/agent/runtime/state/runner-state.mjs +1 -0
- package/src/agent/runtime/ui-event-bridge.mjs +2 -0
- package/src/agent/tools.mjs +3 -0
- package/src/cli/commands/registry/slash-command-registry.mjs +10 -7
- package/src/cli/commands/status-command.mjs +61 -35
- package/src/context/system-core/base.md +5 -0
- package/src/provider/quota/codex.mjs +278 -0
- package/src/provider/quota/index.mjs +46 -0
- package/src/provider/quota/transport-observer.mjs +99 -0
- package/src/web-ui/runtime-host.mjs +3 -0
- package/src/web-ui/server.mjs +1 -0
- package/src/web-ui/session-manager.mjs +2 -0
- package/src/web-ui/src/components/AppShell.tsx +1 -0
- package/src/web-ui/src/components/RightSidebar.tsx +47 -2
- package/src/web-ui/src/model.ts +20 -0
- package/src/web-ui/src/runtime/client.ts +8 -1
- package/src/web-ui/src/runtime/useWebRuntime.ts +13 -1
- package/src/web-ui/src/styles/shell.css +10 -0
- package/src/web-ui/dist/assets/index-BUmhnID4.css +0 -1
- package/src/web-ui/dist/assets/index-CtuqTjcB.js +0 -1845
- package/src/web-ui/dist/index.html +0 -13
|
@@ -97,13 +97,16 @@ export const SLASH_COMMANDS = [
|
|
|
97
97
|
exactCommand({
|
|
98
98
|
name: "status",
|
|
99
99
|
description: "Show runtime status",
|
|
100
|
-
run: async ({ ui, runner, sessionState, sessionSource }) =>
|
|
101
|
-
runner
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
run: async ({ ui, runner, sessionState, sessionSource }) => {
|
|
101
|
+
await runner.getProviderQuotaSnapshot?.({ emit: true }).catch(() => null);
|
|
102
|
+
return writeLines(ui, statusCommand({
|
|
103
|
+
runner,
|
|
104
|
+
sessionState,
|
|
105
|
+
sessionSource,
|
|
106
|
+
extensionDiagnostics: runner.getExtensionDiagnostics?.() ?? [],
|
|
107
|
+
lifecycleState: runner.getExtensionLifecycleState?.() ?? null,
|
|
108
|
+
}));
|
|
109
|
+
},
|
|
107
110
|
}),
|
|
108
111
|
exactCommand({
|
|
109
112
|
name: "notify",
|
|
@@ -10,15 +10,20 @@ export function statusCommand({
|
|
|
10
10
|
lifecycleState = null,
|
|
11
11
|
gitBranch = getGitBranch(runner.engine.cwd),
|
|
12
12
|
}) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
const providerQuota = runner.getCachedProviderQuotaSnapshot?.() ?? null;
|
|
14
|
+
const lines = [
|
|
15
|
+
...formatStatusLines({
|
|
16
|
+
engine: runner.engine,
|
|
17
|
+
sessionState,
|
|
18
|
+
sessionStats: runner.getSessionStats?.() ?? null,
|
|
19
|
+
sessionSource,
|
|
20
|
+
extensionDiagnostics,
|
|
21
|
+
lifecycleState,
|
|
22
|
+
gitBranch,
|
|
23
|
+
}),
|
|
24
|
+
...formatProviderQuotaLines(providerQuota),
|
|
25
|
+
];
|
|
26
|
+
return lines.length > 0 ? lines : ["No provider quota available."];
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function statusBarLine({
|
|
@@ -37,35 +42,48 @@ export function statusBarLine({
|
|
|
37
42
|
});
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
export function formatStatusLine({
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
export function formatStatusLine(options) {
|
|
46
|
+
return formatStatusLines(options).join(" ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatStatusLines({
|
|
45
50
|
extensionDiagnostics = [],
|
|
46
51
|
lifecycleState = null,
|
|
47
|
-
gitBranch = null,
|
|
48
52
|
}) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
53
|
+
const diagnosticSummary = formatExtensionDiagnosticSummary(extensionDiagnostics, lifecycleState);
|
|
54
|
+
return shouldShowDiagnostics(diagnosticSummary) ? [`Extensions: ${diagnosticSummary}`] : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatProviderQuotaLines(providerQuota, { width = 20 } = {}) {
|
|
58
|
+
const windows = providerQuota?.limits?.flatMap((limit) => limit.windows ?? []) ?? [];
|
|
59
|
+
return windows.slice(0, 2).map((window) => formatProviderQuotaLine(window, { width }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatProviderQuotaLine(window, { width = 20 } = {}) {
|
|
63
|
+
const label = window.label === "weekly" ? "Weekly limit:" : `${window.label} limit:`;
|
|
64
|
+
const left = formatPercent(window.remainingPercent);
|
|
65
|
+
return `${label.padEnd(28)} ${formatQuotaBar(window.remainingPercent, width)} ${left}% left (${formatQuotaReset(window.resetsAt)})`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatQuotaBar(percent, width = 20) {
|
|
69
|
+
const value = Math.max(0, Math.min(100, Number(percent) || 0));
|
|
70
|
+
const filled = Math.round((value / 100) * width);
|
|
71
|
+
return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatQuotaReset(resetsAt) {
|
|
75
|
+
if (!resetsAt) return "reset unknown";
|
|
76
|
+
const date = new Date(resetsAt);
|
|
77
|
+
if (Number.isNaN(date.getTime())) return "reset unknown";
|
|
78
|
+
return `resets ${formatResetDate(date)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatResetDate(date) {
|
|
82
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
83
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
84
|
+
const day = date.getDate();
|
|
85
|
+
const month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][date.getMonth()];
|
|
86
|
+
return `${hours}:${minutes} on ${day} ${month}`;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
export function formatStatusBarLine({
|
|
@@ -154,6 +172,10 @@ export function formatCompactTokenCount(tokens) {
|
|
|
154
172
|
return `${formatOneDecimal(value / 1000000)}M`;
|
|
155
173
|
}
|
|
156
174
|
|
|
175
|
+
function shouldShowDiagnostics(summary) {
|
|
176
|
+
return summary !== "ok" && summary.split(",").some((part) => !part.endsWith("info"));
|
|
177
|
+
}
|
|
178
|
+
|
|
157
179
|
export function formatExtensionDiagnosticSummary(extensionDiagnostics = [], lifecycleState = null) {
|
|
158
180
|
const diagnostics = [...extensionDiagnostics, ...(lifecycleState?.diagnostics ?? [])];
|
|
159
181
|
if (diagnostics.length === 0) return "ok";
|
|
@@ -194,3 +216,7 @@ function formatOneDecimal(value) {
|
|
|
194
216
|
const rounded = Math.round(value * 10) / 10;
|
|
195
217
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
|
196
218
|
}
|
|
219
|
+
|
|
220
|
+
function formatPercent(value) {
|
|
221
|
+
return Math.round(Math.max(0, Math.min(100, Number(value) || 0)));
|
|
222
|
+
}
|
|
@@ -103,6 +103,11 @@ The user primarily asks for software engineering work: fixing bugs, adding behav
|
|
|
103
103
|
- Use memory_save() to create memories or update whole metadata fields on an existing memory. Before creating a new memory, first search/open related memories and merge updates into an existing memory when they share the same topic, project, or decision thread; prefer modifying the existing memory file over creating a scattered new one. Tags are the primary retrieval key for future recall. Prefer lowercase-kebab-case tags like 'march-cli', 'tooling', 'permissions'.
|
|
104
104
|
- When learning multiple related external workflows or skills, maintain memory as an evolving domain library: start with the specific source name when only one item exists, then rename and rewrite the memory title/description as the scope grows; merge new related learnings into the same memory, preserving each source's unique traits while distilling reusable principles.
|
|
105
105
|
- Distinguish "migrating a Skill to memory" from "learning a Skill": migration preserves the complete Skill folder under memory_root/skills/ and creates a memory entry as its index; that memory should describe what the Skill is for and reference the copied Skill folder path so future recall knows how to use it. Learning only reads and internalizes the Skill's methods, scenarios, and principles into ordinary memory without copying source files. Infer the action from the user's wording, and ask when ambiguous.
|
|
106
|
+
- Do not proactively modify the agent profile. Update `agent.md` only when the user explicitly asks to change persistent agent behavior.
|
|
107
|
+
- Proactively maintain the user profile when stable, reusable user preferences, working style, goals, or identity signals appear in conversation.
|
|
108
|
+
- For user profile updates, distinguish explicit facts from inferred preferences. Write explicit facts directly; write inferred items as preferences or tendencies, and avoid overstating confidence.
|
|
109
|
+
- Do not store transient task details, sensitive information, or one-off opinions in the user profile. Use memory for project-specific reusable knowledge and current conversation for short-lived context.
|
|
110
|
+
- When a user profile update is non-obvious or potentially sensitive, ask before writing; otherwise update it as part of normal task completion and mention it briefly in the final summary.
|
|
106
111
|
- Unlike recall blocks, this system-core center is always visible in every model call. Only update the center for instructions that must always be followed; use memory for contextual, project-specific, or recall-dependent knowledge.
|
|
107
112
|
- If execution takes a meaningful detour, create or update a memory after the task. A detour means the initial plan or assumption failed, multiple approaches were tried, and the final successful path contains reusable project knowledge. Record the failed assumption, what was tried, and the successful approach. Prefer updating an existing related memory over creating a new one.
|
|
108
113
|
</memory_system>
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
|
2
|
+
|
|
3
|
+
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
4
|
+
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
5
|
+
|
|
6
|
+
export async function fetchOpenAICodexQuota({ authStorage, model, fetchImpl = fetch, now = new Date() } = {}) {
|
|
7
|
+
const token = await resolveCodexAccessToken(authStorage);
|
|
8
|
+
const accountId = resolveCodexAccountId(authStorage, token);
|
|
9
|
+
const response = await fetchImpl(CODEX_USAGE_URL, {
|
|
10
|
+
method: "GET",
|
|
11
|
+
headers: buildCodexUsageHeaders(token, accountId),
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
const text = await response.text().catch(() => "");
|
|
15
|
+
throw new Error(`Codex quota request failed (${response.status}): ${text || response.statusText}`);
|
|
16
|
+
}
|
|
17
|
+
const payload = await response.json();
|
|
18
|
+
return normalizeCodexQuotaPayload(payload, { model, capturedAt: now.toISOString() });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeCodexQuotaPayload(payload, { model = null, capturedAt = new Date().toISOString() } = {}) {
|
|
22
|
+
const snapshots = Array.isArray(payload) ? payload : snapshotsFromCodexUsagePayload(payload);
|
|
23
|
+
return normalizeCodexQuotaSnapshots(snapshots, { model, capturedAt });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeCodexQuotaSnapshots(snapshots, { model = null, capturedAt = new Date().toISOString() } = {}) {
|
|
27
|
+
const limits = snapshots.map(normalizeSnapshot).filter((limit) => limit.windows.length > 0);
|
|
28
|
+
if (limits.length === 0) return null;
|
|
29
|
+
return {
|
|
30
|
+
providerId: "openai-codex",
|
|
31
|
+
modelId: model?.id ?? null,
|
|
32
|
+
label: "GPT usage",
|
|
33
|
+
planType: firstNonEmpty(snapshots.map((snapshot) => readField(snapshot, "planType", "plan_type"))),
|
|
34
|
+
capturedAt,
|
|
35
|
+
limits,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseOpenAICodexQuotaHeaders(headers, { model = null, capturedAt = new Date().toISOString() } = {}) {
|
|
40
|
+
const normalized = normalizeHeaders(headers);
|
|
41
|
+
const limitIds = new Set(["codex"]);
|
|
42
|
+
for (const name of Object.keys(normalized)) {
|
|
43
|
+
const prefix = name.endsWith("-primary-used-percent") ? name.slice(2, -"-primary-used-percent".length) : null;
|
|
44
|
+
if (prefix) limitIds.add(prefix.replaceAll("-", "_"));
|
|
45
|
+
}
|
|
46
|
+
const snapshots = [...limitIds]
|
|
47
|
+
.map((limitId) => snapshotFromHeaders(normalized, limitId))
|
|
48
|
+
.filter((snapshot) => snapshot.primary || snapshot.secondary || snapshot.credits);
|
|
49
|
+
return normalizeCodexQuotaSnapshots(snapshots, { model, capturedAt });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseOpenAICodexQuotaEvent(payload, { model = null, capturedAt = new Date().toISOString() } = {}) {
|
|
53
|
+
const event = typeof payload === "string" ? parseJson(payload) : payload;
|
|
54
|
+
if (!event || event.type !== "codex.rate_limits") return null;
|
|
55
|
+
const snapshot = {
|
|
56
|
+
limitId: readField(event, "metered_limit_name", "limit_name") ?? "codex",
|
|
57
|
+
limitName: null,
|
|
58
|
+
primary: mapWindow(readField(readField(event, "rate_limits", "rateLimits"), "primary")),
|
|
59
|
+
secondary: mapWindow(readField(readField(event, "rate_limits", "rateLimits"), "secondary")),
|
|
60
|
+
credits: readField(event, "credits") ?? null,
|
|
61
|
+
planType: readField(event, "plan_type", "planType") ?? null,
|
|
62
|
+
rateLimitReachedType: null,
|
|
63
|
+
};
|
|
64
|
+
return normalizeCodexQuotaSnapshots([snapshot], { model, capturedAt });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveCodexAccessToken(authStorage) {
|
|
68
|
+
const token = await authStorage?.getApiKey?.("openai-codex", { includeFallback: false });
|
|
69
|
+
if (token) return token;
|
|
70
|
+
const credentials = authStorage?.get?.("openai-codex");
|
|
71
|
+
if (!credentials) throw new Error("OpenAI Codex not authenticated. Run: march login openai-codex");
|
|
72
|
+
const provider = getOAuthProvider("openai-codex");
|
|
73
|
+
if (!provider) throw new Error("OpenAI Codex OAuth provider is not available");
|
|
74
|
+
return provider.getApiKey(credentials);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveCodexAccountId(authStorage, token) {
|
|
78
|
+
const credentials = authStorage?.get?.("openai-codex");
|
|
79
|
+
return credentials?.accountId ?? credentials?.chatgpt_account_id ?? extractAccountId(token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractAccountId(token) {
|
|
83
|
+
try {
|
|
84
|
+
const [, payload] = String(token).split(".");
|
|
85
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
86
|
+
const accountId = parsed?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
|
|
87
|
+
if (typeof accountId === "string" && accountId) return accountId;
|
|
88
|
+
} catch {}
|
|
89
|
+
throw new Error("Failed to extract Codex account ID from token");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildCodexUsageHeaders(token, accountId) {
|
|
93
|
+
return {
|
|
94
|
+
authorization: `Bearer ${token}`,
|
|
95
|
+
"chatgpt-account-id": accountId,
|
|
96
|
+
originator: "march",
|
|
97
|
+
"user-agent": "march-cli",
|
|
98
|
+
accept: "application/json",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function snapshotsFromCodexUsagePayload(payload) {
|
|
103
|
+
if (!payload || typeof payload !== "object") return [];
|
|
104
|
+
const planType = readField(payload, "planType", "plan_type") ?? null;
|
|
105
|
+
const reached = readField(payload, "rateLimitReachedType", "rate_limit_reached_type") ?? null;
|
|
106
|
+
const snapshots = [makeSnapshot({
|
|
107
|
+
limitId: "codex",
|
|
108
|
+
limitName: null,
|
|
109
|
+
rateLimit: unwrap(readField(payload, "rateLimit", "rate_limit")),
|
|
110
|
+
credits: unwrap(readField(payload, "credits")),
|
|
111
|
+
planType,
|
|
112
|
+
rateLimitReachedType: unwrap(reached)?.kind ?? reached,
|
|
113
|
+
})];
|
|
114
|
+
const additional = readField(payload, "additionalRateLimits", "additional_rate_limits");
|
|
115
|
+
if (Array.isArray(additional)) {
|
|
116
|
+
for (const item of additional) {
|
|
117
|
+
snapshots.push(makeSnapshot({
|
|
118
|
+
limitId: readField(item, "meteredFeature", "metered_feature"),
|
|
119
|
+
limitName: readField(item, "limitName", "limit_name"),
|
|
120
|
+
rateLimit: unwrap(readField(item, "rateLimit", "rate_limit")),
|
|
121
|
+
credits: null,
|
|
122
|
+
planType,
|
|
123
|
+
rateLimitReachedType: null,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return snapshots;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function makeSnapshot({ limitId, limitName, rateLimit, credits, planType, rateLimitReachedType }) {
|
|
131
|
+
return {
|
|
132
|
+
limitId: limitId ?? null,
|
|
133
|
+
limitName: limitName ?? null,
|
|
134
|
+
primary: mapWindow(readField(rateLimit, "primary", "primaryWindow", "primary_window")),
|
|
135
|
+
secondary: mapWindow(readField(rateLimit, "secondary", "secondaryWindow", "secondary_window")),
|
|
136
|
+
credits: credits ?? null,
|
|
137
|
+
planType,
|
|
138
|
+
rateLimitReachedType,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function snapshotFromHeaders(headers, limitId) {
|
|
143
|
+
const headerPrefix = `x-${limitId.replaceAll("_", "-")}`;
|
|
144
|
+
return {
|
|
145
|
+
limitId,
|
|
146
|
+
limitName: readHeader(headers, `${headerPrefix}-limit-name`),
|
|
147
|
+
primary: windowFromHeaders(headers, headerPrefix, "primary"),
|
|
148
|
+
secondary: windowFromHeaders(headers, headerPrefix, "secondary"),
|
|
149
|
+
credits: limitId === "codex" ? creditsFromHeaders(headers) : null,
|
|
150
|
+
planType: null,
|
|
151
|
+
rateLimitReachedType: null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function windowFromHeaders(headers, prefix, windowId) {
|
|
156
|
+
const usedPercent = readHeader(headers, `${prefix}-${windowId}-used-percent`);
|
|
157
|
+
if (usedPercent === undefined) return null;
|
|
158
|
+
return {
|
|
159
|
+
usedPercent,
|
|
160
|
+
windowDurationMins: readHeader(headers, `${prefix}-${windowId}-window-minutes`),
|
|
161
|
+
resetsAt: readHeader(headers, `${prefix}-${windowId}-reset-at`),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function creditsFromHeaders(headers) {
|
|
166
|
+
const hasCredits = parseHeaderBool(readHeader(headers, "x-codex-credits-has-credits"));
|
|
167
|
+
const unlimited = parseHeaderBool(readHeader(headers, "x-codex-credits-unlimited"));
|
|
168
|
+
if (hasCredits === null || unlimited === null) return null;
|
|
169
|
+
return { hasCredits, unlimited, balance: readHeader(headers, "x-codex-credits-balance") ?? null };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeSnapshot(snapshot) {
|
|
173
|
+
const id = readField(snapshot, "limitId", "limit_id") ?? "quota";
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
name: readField(snapshot, "limitName", "limit_name") ?? id,
|
|
177
|
+
windows: [
|
|
178
|
+
normalizeWindow("primary", readField(snapshot, "primary")),
|
|
179
|
+
normalizeWindow("secondary", readField(snapshot, "secondary")),
|
|
180
|
+
].filter(Boolean),
|
|
181
|
+
rateLimitReachedType: readField(snapshot, "rateLimitReachedType", "rate_limit_reached_type") ?? null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeWindow(id, window) {
|
|
186
|
+
const normalized = mapWindow(window);
|
|
187
|
+
if (!normalized) return null;
|
|
188
|
+
const label = formatWindowLabel(normalized.windowDurationMins, id);
|
|
189
|
+
return {
|
|
190
|
+
id,
|
|
191
|
+
label,
|
|
192
|
+
usedPercent: normalized.usedPercent,
|
|
193
|
+
remainingPercent: Math.max(0, 100 - normalized.usedPercent),
|
|
194
|
+
windowDurationMins: normalized.windowDurationMins,
|
|
195
|
+
resetsAt: normalized.resetsAt,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function mapWindow(window) {
|
|
200
|
+
const unwrapped = unwrap(window);
|
|
201
|
+
if (!unwrapped || typeof unwrapped !== "object") return null;
|
|
202
|
+
const rawUsed = readField(unwrapped, "usedPercent", "used_percent");
|
|
203
|
+
const usedPercent = Number(rawUsed);
|
|
204
|
+
if (!Number.isFinite(usedPercent)) return null;
|
|
205
|
+
const rawMinutes = readField(unwrapped, "windowDurationMins", "window_minutes", "windowDurationMinutes");
|
|
206
|
+
const rawSeconds = readField(unwrapped, "limitWindowSeconds", "limit_window_seconds");
|
|
207
|
+
return {
|
|
208
|
+
usedPercent,
|
|
209
|
+
windowDurationMins: normalizeWindowMinutes(rawMinutes, rawSeconds),
|
|
210
|
+
resetsAt: normalizeResetTime(readField(unwrapped, "resetsAt", "resets_at", "resetAt", "reset_at")),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeWindowMinutes(minutes, seconds) {
|
|
215
|
+
const minuteValue = Number(minutes);
|
|
216
|
+
if (Number.isFinite(minuteValue) && minuteValue > 0) return minuteValue;
|
|
217
|
+
const secondValue = Number(seconds);
|
|
218
|
+
return Number.isFinite(secondValue) && secondValue > 0 ? Math.round(secondValue / 60) : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeResetTime(value) {
|
|
222
|
+
const numeric = Number(value);
|
|
223
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
224
|
+
const millis = numeric < 10_000_000_000 ? numeric * 1000 : numeric;
|
|
225
|
+
return new Date(millis).toISOString();
|
|
226
|
+
}
|
|
227
|
+
const parsed = Date.parse(String(value));
|
|
228
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatWindowLabel(minutes, fallback) {
|
|
232
|
+
if (!Number.isFinite(minutes) || minutes <= 0) return fallback;
|
|
233
|
+
if (minutes % (60 * 24 * 7) === 0) return "weekly";
|
|
234
|
+
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
|
235
|
+
return `${minutes}m`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function unwrap(value) {
|
|
239
|
+
return Array.isArray(value) && value.length === 1 ? value[0] : value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readField(object, ...keys) {
|
|
243
|
+
if (!object || typeof object !== "object") return undefined;
|
|
244
|
+
for (const key of keys) if (Object.hasOwn(object, key)) return object[key];
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeHeaders(headers) {
|
|
249
|
+
if (!headers) return {};
|
|
250
|
+
if (typeof headers.entries === "function") {
|
|
251
|
+
return Object.fromEntries([...headers.entries()].map(([key, value]) => [key.toLowerCase(), String(value)]));
|
|
252
|
+
}
|
|
253
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), String(value)]));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readHeader(headers, name) {
|
|
257
|
+
const value = headers[name.toLowerCase()];
|
|
258
|
+
return value === undefined || value === "" ? undefined : value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseHeaderBool(value) {
|
|
262
|
+
if (value === undefined) return null;
|
|
263
|
+
if (value === "1" || value.toLowerCase() === "true") return true;
|
|
264
|
+
if (value === "0" || value.toLowerCase() === "false") return false;
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseJson(value) {
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(value);
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function firstNonEmpty(values) {
|
|
277
|
+
return values.find((value) => value !== null && value !== undefined && value !== "") ?? null;
|
|
278
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fetchOpenAICodexQuota, parseOpenAICodexQuotaEvent, parseOpenAICodexQuotaHeaders } from "./codex.mjs";
|
|
2
|
+
|
|
3
|
+
const quotaAdapters = new Map([
|
|
4
|
+
["openai-codex", {
|
|
5
|
+
refresh: fetchOpenAICodexQuota,
|
|
6
|
+
observeHeaders: parseOpenAICodexQuotaHeaders,
|
|
7
|
+
observeEvent: parseOpenAICodexQuotaEvent,
|
|
8
|
+
}],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export function supportsProviderQuota(providerId) {
|
|
12
|
+
return quotaAdapters.has(providerId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function getProviderQuotaSnapshot({ providerId, model, authStorage, fetchImpl, now } = {}) {
|
|
16
|
+
const adapter = quotaAdapters.get(providerId);
|
|
17
|
+
if (!adapter) return null;
|
|
18
|
+
return adapter.refresh({ authStorage, model, fetchImpl, now });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function observeProviderQuotaHeaders({ providerId, headers, model, capturedAt } = {}) {
|
|
22
|
+
const adapter = quotaAdapters.get(providerId);
|
|
23
|
+
return adapter?.observeHeaders?.(headers, { model, capturedAt }) ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function observeProviderQuotaEvent({ providerId, payload, model, capturedAt } = {}) {
|
|
27
|
+
const adapter = quotaAdapters.get(providerId);
|
|
28
|
+
return adapter?.observeEvent?.(payload, { model, capturedAt }) ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createProviderQuotaService({ authStorage, fetchImpl = fetch, now = () => new Date() } = {}) {
|
|
32
|
+
return {
|
|
33
|
+
supports(providerId) {
|
|
34
|
+
return supportsProviderQuota(providerId);
|
|
35
|
+
},
|
|
36
|
+
refresh(model) {
|
|
37
|
+
return getProviderQuotaSnapshot({ providerId: model?.provider, model, authStorage, fetchImpl, now: now() });
|
|
38
|
+
},
|
|
39
|
+
observeHeaders(headers, model) {
|
|
40
|
+
return observeProviderQuotaHeaders({ providerId: model?.provider, headers, model, capturedAt: now().toISOString() });
|
|
41
|
+
},
|
|
42
|
+
observeEvent(payload, model) {
|
|
43
|
+
return observeProviderQuotaEvent({ providerId: model?.provider, payload, model, capturedAt: now().toISOString() });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const FETCH_INSTALLED = Symbol.for("march.providerQuota.fetchObserverInstalled");
|
|
2
|
+
const WEBSOCKET_INSTALLED = Symbol.for("march.providerQuota.websocketObserverInstalled");
|
|
3
|
+
const LISTENERS = Symbol.for("march.providerQuota.transportListeners");
|
|
4
|
+
|
|
5
|
+
export function installProviderQuotaTransportObserver() {
|
|
6
|
+
installFetchObserver();
|
|
7
|
+
installWebSocketObserver();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function subscribeProviderQuotaTransport(listener) {
|
|
11
|
+
const listeners = ensureListeners();
|
|
12
|
+
listeners.add(listener);
|
|
13
|
+
return () => listeners.delete(listener);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function installFetchObserver() {
|
|
17
|
+
if (globalThis[FETCH_INSTALLED]) return;
|
|
18
|
+
const originalFetch = globalThis.fetch;
|
|
19
|
+
if (typeof originalFetch !== "function") return;
|
|
20
|
+
globalThis.fetch = async function marchProviderQuotaFetch(input, init = {}) {
|
|
21
|
+
const response = await originalFetch.call(this, input, init);
|
|
22
|
+
if (isCodexResponsesHttpRequest(input, init)) {
|
|
23
|
+
notifyTransportListeners({ providerId: "openai-codex", source: "headers", headers: response.headers });
|
|
24
|
+
}
|
|
25
|
+
return response;
|
|
26
|
+
};
|
|
27
|
+
globalThis[FETCH_INSTALLED] = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function installWebSocketObserver() {
|
|
31
|
+
if (globalThis[WEBSOCKET_INSTALLED]) return;
|
|
32
|
+
const OriginalWebSocket = globalThis.WebSocket;
|
|
33
|
+
if (typeof OriginalWebSocket !== "function") return;
|
|
34
|
+
|
|
35
|
+
class MarchProviderQuotaWebSocket extends OriginalWebSocket {
|
|
36
|
+
constructor(url, protocolsOrOptions, maybeOptions) {
|
|
37
|
+
if (!isCodexResponsesWebSocketUrl(url)) {
|
|
38
|
+
return new OriginalWebSocket(url, protocolsOrOptions, maybeOptions);
|
|
39
|
+
}
|
|
40
|
+
super(url, protocolsOrOptions, maybeOptions);
|
|
41
|
+
this.addEventListener?.("message", async (event) => {
|
|
42
|
+
const payload = await decodeWebSocketData(event?.data).catch(() => null);
|
|
43
|
+
if (payload?.includes?.('"codex.rate_limits"')) {
|
|
44
|
+
notifyTransportListeners({ providerId: "openai-codex", source: "event", payload });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
copyReadyStateConstants(MarchProviderQuotaWebSocket, OriginalWebSocket);
|
|
50
|
+
globalThis.WebSocket = MarchProviderQuotaWebSocket;
|
|
51
|
+
globalThis[WEBSOCKET_INSTALLED] = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureListeners() {
|
|
55
|
+
globalThis[LISTENERS] = globalThis[LISTENERS] ?? new Set();
|
|
56
|
+
return globalThis[LISTENERS];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function notifyTransportListeners(event) {
|
|
60
|
+
for (const listener of [...ensureListeners()]) {
|
|
61
|
+
try {
|
|
62
|
+
listener(event);
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isCodexResponsesHttpRequest(input, init) {
|
|
68
|
+
const url = getRequestUrl(input);
|
|
69
|
+
if (!url || !url.includes("/codex/responses")) return false;
|
|
70
|
+
const method = init?.method ?? input?.method ?? "GET";
|
|
71
|
+
return String(method).toUpperCase() === "POST";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isCodexResponsesWebSocketUrl(url) {
|
|
75
|
+
const raw = getRequestUrl(url);
|
|
76
|
+
return Boolean(raw?.includes("/codex/responses") && (raw.startsWith("ws://") || raw.startsWith("wss://")));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getRequestUrl(input) {
|
|
80
|
+
if (typeof input === "string") return input;
|
|
81
|
+
if (input instanceof URL) return input.toString();
|
|
82
|
+
if (input && typeof input.url === "string") return input.url;
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function decodeWebSocketData(data) {
|
|
87
|
+
if (typeof data === "string") return data;
|
|
88
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
|
89
|
+
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
90
|
+
if (data && typeof data.arrayBuffer === "function") return new TextDecoder().decode(new Uint8Array(await data.arrayBuffer()));
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function copyReadyStateConstants(Target, OriginalWebSocket) {
|
|
95
|
+
for (const key of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]) {
|
|
96
|
+
const value = OriginalWebSocket[key];
|
|
97
|
+
if (typeof value === "number") Object.defineProperty(Target, key, { value, enumerable: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -96,6 +96,7 @@ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRu
|
|
|
96
96
|
currentProject,
|
|
97
97
|
snapshot: () => createWebSnapshot({ cwd, runner, currentProject }),
|
|
98
98
|
subscribe: (listener) => runner.runtimeUiEvents.on(listener),
|
|
99
|
+
refreshProviderQuota: () => runner.getProviderQuotaSnapshot?.({ emit: true }) ?? null,
|
|
99
100
|
async runTurn(prompt) {
|
|
100
101
|
if (turnRunning) throw new Error("A turn is already running");
|
|
101
102
|
turnRunning = true;
|
|
@@ -123,6 +124,7 @@ export function createHeadlessWebUi() {
|
|
|
123
124
|
thinkingBlock: () => {}, toggleLastThinking: () => false,
|
|
124
125
|
toolStart: () => {}, toolEnd: () => {}, textDelta: () => {},
|
|
125
126
|
assistantReplyEnd: () => {}, status: () => {}, recall: () => {},
|
|
127
|
+
providerQuotaSnapshot: () => {},
|
|
126
128
|
clearOutput: () => {}, restoreTranscript: () => {}, setStatusBar: () => {},
|
|
127
129
|
turnStart: () => {}, turnEnd: () => {}, retryStart: () => {}, retryEnd: () => {},
|
|
128
130
|
editDiff: () => {}, requestPermission: async () => true,
|
|
@@ -138,6 +140,7 @@ export function createWebSnapshot({ cwd, runner, currentProject = basename(cwd)
|
|
|
138
140
|
return {
|
|
139
141
|
workspace: readWorkspaceTree(cwd),
|
|
140
142
|
timeline: { title: currentProject, meta: runtimeMeta(model), events: [] },
|
|
143
|
+
providerQuota: runner.getCachedProviderQuotaSnapshot?.() ?? null,
|
|
141
144
|
sessions: [{ id: "current", title: runner.engine?.sessionName ?? currentProject, time: "now", active: true }],
|
|
142
145
|
activity: [{ id: "runtime", action: "runner connected", time: "now" }],
|
|
143
146
|
composer: { mode: "Chat", placeholder: "Message March…" },
|
package/src/web-ui/server.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export async function handleApiRequest(req, res, runtime) {
|
|
|
34
34
|
if (req.method === "POST" && pathname === "/api/sessions") return sendJson(res, await createRuntimeSession(req, runtime));
|
|
35
35
|
if (req.method === "GET" && pathname === "/api/fs/roots") return sendJson(res, { roots: runtime.fsRoots() });
|
|
36
36
|
if (req.method === "GET" && pathname === "/api/fs/list") return sendJson(res, { entries: runtime.fsList(getPathParam(req)) });
|
|
37
|
+
if (req.method === "GET" && pathname === "/api/provider-quota") return sendJson(res, { snapshot: await runtime.refreshProviderQuota(getSessionId(req)) });
|
|
37
38
|
if (req.method === "POST" && pathname === "/api/turn") return sendJson(res, await runRuntimeTurn(req, runtime));
|
|
38
39
|
if (req.method === "POST" && pathname === "/api/abort") return sendJson(res, { ok: true, result: runtime.abort(getSessionId(req)) });
|
|
39
40
|
sendJson(res, { error: "Not found" }, 404);
|
|
@@ -33,6 +33,7 @@ export function createWebSessionManager({ args, config, launchCwd, stateRoot, us
|
|
|
33
33
|
},
|
|
34
34
|
subscribe(sessionId, listener) { return getSession(sessions, sessionId).runtime.subscribe(listener); },
|
|
35
35
|
runTurn(sessionId, prompt) { return getSession(sessions, sessionId).runtime.runTurn(prompt); },
|
|
36
|
+
refreshProviderQuota(sessionId) { return getSession(sessions, sessionId).runtime.refreshProviderQuota?.() ?? null; },
|
|
36
37
|
abort(sessionId) { return getSession(sessions, sessionId).runtime.abort?.(); },
|
|
37
38
|
fsRoots() { return listFsRoots(launchCwd); },
|
|
38
39
|
fsList(path) { return listFsDirectory(path); },
|
|
@@ -70,6 +71,7 @@ function createEmptySnapshot({ sessions, activeSessionId, activities }) {
|
|
|
70
71
|
workspace: { id: "no-workspace", name: "Choose workspace", kind: "folder", selected: true },
|
|
71
72
|
timeline: { title: "No session selected", meta: "Create a session and bind a workspace", events: [] },
|
|
72
73
|
sessions: Array.from(sessions.values()).map((session) => toSessionSummary(session, session.id === activeSessionId)),
|
|
74
|
+
providerQuota: null,
|
|
73
75
|
activity: activities,
|
|
74
76
|
activeSessionId: null,
|
|
75
77
|
composer: { mode: "No session", placeholder: "Choose a workspace to start…" },
|
|
@@ -28,6 +28,7 @@ export function AppShell({ runtime }: AppShellProps) {
|
|
|
28
28
|
activity={model.activity}
|
|
29
29
|
fsEntries={runtime.fsEntries}
|
|
30
30
|
fsPath={runtime.fsPath}
|
|
31
|
+
providerQuota={model.providerQuota}
|
|
31
32
|
running={runtime.running}
|
|
32
33
|
onOpenSession={runtime.openSession}
|
|
33
34
|
onCreateSession={runtime.createSession}
|